Fixes issue 1079 "Clarify and enhance setStyleName(), getStyleName(), addStyleName(), and removeStyleName()" Patch by: jgw, bruce, knorton Review by: jgw, bruce git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@1063 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/user/src/com/google/gwt/user/client/ui/UIObject.java b/user/src/com/google/gwt/user/client/ui/UIObject.java index 15e4e83..218f9d7 100644 --- a/user/src/com/google/gwt/user/client/ui/UIObject.java +++ b/user/src/com/google/gwt/user/client/ui/UIObject.java
@@ -1,12 +1,12 @@ /* * Copyright 2007 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 @@ -19,12 +19,74 @@ import com.google.gwt.user.client.Element; /** - * The base class for all user-interface objects. It simply wraps a DOM element, + * The superclass for all user-interface objects. It simply wraps a DOM element, * and cannot receive events. Most interesting user-interface classes derive * from {@link com.google.gwt.user.client.ui.Widget}. + * + * <h3>Styling With CSS</h3> + * <p> + * All <code>UIObject</code> objects can be styled using CSS. Style names that + * are specified programmatically in Java source are implicitly associated with + * CSS style rules. In terms of HTML and CSS, a GWT style name is the element's + * CSS "class". By convention, GWT style names are of the form + * <code>[project]-[widget]</code>. + * </p> + * + * <p> + * For example, the {@link Button} widget has the style name + * <code>gwt-Button</code>, meaning that within the <code>Button</code> + * constructor, the following call occurs: + * + * <pre class="code"> + * setStyleName("gwt-Button");</pre> + * + * A corresponding CSS style rule can then be written as follows: + * + * <pre class="code"> + * // Example of how you might choose to style a Button widget + * .gwt-Button { + * background-color: yellow; + * color: black; + * font-size: 24pt; + * }</pre> + * + * Note the dot prefix in the CSS style rule. This syntax is called a <a + * href="http://www.w3.org/TR/REC-CSS2/selector.html#class-html">CSS class + * selector</a>. + * </p> + * + * <h3>Style Name Specifics</h3> + * <p> + * Every <code>UIObject</code> has a <i>primary style name</i> that + * identifies the key CSS style rule that should always be applied to it. Use + * {@link #setStyleName(String)} to specify an object's primary style name. In + * most cases, the primary style name is set in a widget's constructor and never + * changes again during execution. In the case that no primary style name is + * specified, it defaults to <code>gwt-nostyle</code>. + * </p> + * + * <p> + * More complex styling behavior can be achieved by manipulating an object's + * <i>secondary style names</i>. Secondary style names can be added and removed + * using {@link #addStyleName(String)} and {@link #removeStyleName(String)}. + * The purpose of secondary style names is to associate a variety of CSS style + * rules over time as an object progresses through different visual states. + * </p> + * + * <p> + * There is an important special formulation of secondary style names called + * <i>dependent style names</i>. A dependent style name is a secondary style + * name prefixed with the primary style name of the widget itself. See + * {@link #addStyleName(String)} for details. + * </p> */ public abstract class UIObject { - private static final String NULL_HANDLE_MSG = "Null widget handle. If you " + + private static final String EMPTY_STYLENAME_MSG = "Style names cannot be empty"; + + private static final String STYLE_EMPTY = "gwt-nostyle"; + + private static final String NULL_HANDLE_MSG = "Null widget handle. If you " + "are creating a composite, ensure that initWidget() has been called."; public static native boolean isVisible(Element elem) /*-{ @@ -36,12 +98,12 @@ }-*/; /** - * This convenience method implements allows one to easily add or remove the - * style name for any element. This can be useful when you need to add and - * remove styles from a sub-element within a {@link UIObject}. - * + * This convenience method adds or removes a secondary style name to the + * primary style name for a given element. Set {@link #setStyleName(String)} + * for a description of how primary and secondary style names are used. + * * @param elem the element whose style is to be modified - * @param style the style name to be added or removed + * @param style the secondary style name to be added or removed * @param add <code>true</code> to add the given style, <code>false</code> * to remove it */ @@ -49,9 +111,10 @@ if (elem == null) { throw new RuntimeException(NULL_HANDLE_MSG); } + + style = style.trim(); if (style.length() == 0) { - throw new IllegalArgumentException( - "Cannot pass is an empty string as a style name."); + throw new IllegalArgumentException(EMPTY_STYLENAME_MSG); } // Get the current style string. @@ -88,6 +151,10 @@ } else { // Don't try to remove the style if it's not there. if (idx != -1) { + if (idx == 0) { + // You can't remove the base (i.e. the first) style name. + throw new IllegalArgumentException("Cannot remove base style name"); + } String begin = oldStyle.substring(0, idx); String end = oldStyle.substring(idx + style.length()); DOM.setElementProperty(elem, "className", begin + end); @@ -98,9 +165,82 @@ private Element element; /** - * Adds a style name to the widget. + * Adds a secondary or dependent style name to this object. A secondary style + * name is an additional style name that is, in HTML/CSS terms, included as a + * space-separated token in the value of the CSS <code>class</code> + * attribute for this object's root element. + * + * <p> + * The most important use for this method is to add a special kind of + * secondary style name called a <i>dependent style name</i>. To add a + * dependent style name, prefix the 'style' argument with the result of + * {@link #getStyleName()}. For example, suppose the primary style name is + * <code>gwt-TextBox</code>. If the following method is called as + * <code>obj.setReadOnly(true)</code>: + * </p> + * + * <pre class="code"> + * public void setReadOnly(boolean readOnly) { + * isReadOnlyMode = readOnly; + * + * // Create a dependent style name. + * String readOnlyStyle = getStyleName() + "-readonly"; + * + * if (readOnly) { + * addStyleName(readOnlyStyle); + * } else { + * removeStyleName(readOnlyStyle); + * } + * }</pre> + * + * <p> + * then both of the CSS style rules below will be applied: + * </p> + * + * <pre class="code"> * - * @param style the style name to be added + * // This rule is based on the primary style name and is always active. + * .gwt-TextBox { + * font-size: 12pt; + * } + * + * // This rule is based on a dependent style name that is only active + * // when the widget has called addStyleName(getStyleName() + "-readonly"). + * .gwt-TextBox-readonly { + * background-color: lightgrey; + * border: none; + * }</pre> + * + * <p> + * Dependent style names are powerful because they are automatically updated + * whenever the primary style name changes. Continuing with the example above, + * if the primary style name changed due to the following call: + * </p> + * + * <pre class="code">setStyleName("my-TextThingy");</pre> + * + * <p> + * then the object would be re-associated with style rules below rather than + * those above: + * </p> + * + * <pre class="code"> + * .my-TextThingy { + * font-size: 12pt; + * } + * + * .my-TextThingy-readonly { + * background-color: lightgrey; + * border: none; + * }</pre> + * + * <p> + * Secondary style names that are not dependent style names are not + * automatically updated when the primary style name changes. + * </p> + * + * @param style the secondary style name to be added + * @see UIObject * @see #removeStyleName(String) */ public void addStyleName(String style) { @@ -110,7 +250,7 @@ /** * Gets the object's absolute left position in pixels, as measured from the * browser window's client area. - * + * * @return the object's absolute left position */ public int getAbsoluteLeft() { @@ -120,7 +260,7 @@ /** * Gets the object's absolute top position in pixels, as measured from the * browser window's client area. - * + * * @return the object's absolute top position */ public int getAbsoluteTop() { @@ -129,7 +269,7 @@ /** * Gets a handle to the object's underlying DOM element. - * + * * @return the object's browser element */ public Element getElement() { @@ -139,7 +279,7 @@ /** * Gets the object's offset height in pixels. This is the total height of the * object, including decorations such as border, margin, and padding. - * + * * @return the object's offset height */ public int getOffsetHeight() { @@ -149,7 +289,7 @@ /** * Gets the object's offset width in pixels. This is the total width of the * object, including decorations such as border, margin, and padding. - * + * * @return the object's offset width */ public int getOffsetWidth() { @@ -157,19 +297,30 @@ } /** - * Gets the style name associated with the object. - * - * @return the object's style name + * Gets the primary style name associated with the object. + * + * @return the object's primary style name * @see #setStyleName(String) + * @see #addStyleName(String) + * @see #removeStyleName(String) */ public String getStyleName() { - return DOM.getElementProperty(element, "className"); + String fullClassName = DOM.getElementProperty(element, "className"); + + // The base style name is always the first token of the full CSS class + // name. There can be no leading whitespace in the class name, so it's not + // necessary to trim() it. + int spaceIdx = fullClassName.indexOf(' '); + if (spaceIdx >= 0) { + return fullClassName.substring(0, spaceIdx); + } + return fullClassName; } /** * Gets the title associated with this object. The title is the 'tool-tip' * displayed to users when they hover over the object. - * + * * @return the object's title */ public String getTitle() { @@ -178,7 +329,7 @@ /** * Determines whether or not this object is visible. - * + * * @return <code>true</code> if the object is visible */ public boolean isVisible() { @@ -186,9 +337,9 @@ } /** - * Removes a style name from the widget. - * - * @param style the style name to be added + * Removes a secondary style name. + * + * @param style the secondary style name to be removed * @see #addStyleName(String) */ public void removeStyleName(String style) { @@ -198,21 +349,20 @@ /** * Sets the object's height. This height does not include decorations such as * border, margin, and padding. - * + * * @param height the object's new height, in CSS units (e.g. "10px", "1em") */ public void setHeight(String height) { // This exists to deal with an inconsistency in IE's implementation where // it won't accept negative numbers in length measurements - assert extractLengthValue(height.trim().toLowerCase()) >= 0 : - "CSS heights should not be negative"; + assert extractLengthValue(height.trim().toLowerCase()) >= 0 : "CSS heights should not be negative"; DOM.setStyleAttribute(element, "height", height); } /** * Sets the object's size, in pixels, not including decorations such as * border, margin, and padding. - * + * * @param width the object's new width, in pixels * @param height the object's new height, in pixels */ @@ -228,7 +378,7 @@ /** * Sets the object's size. This size does not include decorations such as * border, margin, and padding. - * + * * @param width the object's new width, in CSS units (e.g. "10px", "1em") * @param height the object's new height, in CSS units (e.g. "10px", "1em") */ @@ -238,31 +388,9 @@ } /** - * Sets the object's style name, removing all other styles. - * - * <p> - * The style name is the name referred to in CSS style rules (in HTML, this is - * referred to as the element's "class"). By convention, style rules are of - * the form <code>[project]-[widget]</code> (e.g. the {@link Button} - * widget's style name is <code>.gwt-Button</code>). - * </p> - * - * <p> - * For example, if a widget's style name is <code>myProject-MyWidget</code>, - * then the style rule that applies to it will be - * <code>.myProject-MyWidget</code>. Note the "dot" prefix -- this is - * necessary because calling this method sets the underlying element's - * <code>className</code> property. - * </p> - * - * <p> - * An object may have any number of style names, which may be manipulated - * using {@link #addStyleName(String)} and {@link #removeStyleName(String)}. - * The attributes of all styles associated with the object will be applied to - * it. - * </p> - * - * @param style the style name to be added + * Sets the object's primary style name and updates all dependent style names. + * + * @param style the new primary style name * @see #addStyleName(String) * @see #removeStyleName(String) */ @@ -271,13 +399,20 @@ throw new RuntimeException(NULL_HANDLE_MSG); } - DOM.setElementProperty(element, "className", style); + // Style names cannot contain leading or trailing whitespace, and cannot + // legally be empty. + style = style.trim(); + if (style.length() == 0) { + throw new IllegalArgumentException(EMPTY_STYLENAME_MSG); + } + + updatePrimaryAndDependentStyleNames(element, style); } /** * Sets the title associated with this object. The title is the 'tool-tip' * displayed to users when they hover over the object. - * + * * @param title the object's new title */ public void setTitle(String title) { @@ -290,7 +425,7 @@ /** * Sets whether this object is visible. - * + * * @param visible <code>true</code> to show the object, <code>false</code> * to hide it */ @@ -301,14 +436,13 @@ /** * Sets the object's width. This width does not include decorations such as * border, margin, and padding. - * + * * @param width the object's new width, in CSS units (e.g. "10px", "1em") */ public void setWidth(String width) { // This exists to deal with an inconsistency in IE's implementation where // it won't accept negative numbers in length measurements - assert extractLengthValue(width.trim().toLowerCase()) >= 0 : - "CSS widths should not be negative"; + assert extractLengthValue(width.trim().toLowerCase()) >= 0 : "CSS widths should not be negative"; DOM.setStyleAttribute(element, "width", width); } @@ -316,7 +450,7 @@ * Adds a set of events to be sunk by this object. Note that only * {@link Widget widgets} may actually receive events, but can receive events * from all objects contained within them. - * + * * @param eventBitsToAdd a bitfield representing the set of events to be added * to this element's event set * @see com.google.gwt.user.client.Event @@ -329,7 +463,7 @@ /** * This method is overridden so that any object can be viewed in the debugger * as an HTML snippet. - * + * * @return a string representation of the object */ public String toString() { @@ -341,7 +475,7 @@ /** * Removes a set of events from this object's event list. - * + * * @param eventBitsToRemove a bitfield representing the set of events to be * removed from this element's event set * @see #sinkEvents @@ -355,11 +489,11 @@ /** * Sets this object's browser element. UIObject subclasses must call this * method before attempting to call any other methods. - * + * * If the browser element has already been set, then the current element's * position is located in the DOM and removed. The new element is added into * the previous element's position. - * + * * @param elem the object's new element */ protected void setElement(Element elem) { @@ -367,16 +501,19 @@ // replace this.element in its parent with elem. replaceNode(this.element, elem); } + this.element = elem; + DOM.setElementProperty(element, "className", STYLE_EMPTY); } /** - * Intended to be used to pull the value out of a CSS length. We rely - * on the behavior of parseFloat to ignore non-numeric chars in its - * input. If the value is "auto" or "inherit", 0 will be returned. - * @param s The CSS length string to extract - * @return The leading numeric portion of <code>s</code>, or 0 if - * "auto" or "inherit" are passed in. + * Intended to be used to pull the value out of a CSS length. We rely on the + * behavior of parseFloat to ignore non-numeric chars in its input. If the + * value is "auto" or "inherit", 0 will be returned. + * + * @param s The CSS length string to extract + * @return The leading numeric portion of <code>s</code>, or 0 if "auto" or + * "inherit" are passed in. */ private native double extractLengthValue(String s) /*-{ if (s == "auto" || s == "inherit") { @@ -394,4 +531,40 @@ p.insertBefore(newNode, node); p.removeChild(node); }-*/; + + /** + * Replaces all instances of the primary style name with newPrimaryStyleName. + */ + private native void updatePrimaryAndDependentStyleNames(Element elem, String newStyle) /*-{ + var className = elem.className; + + var spaceIdx = className.indexOf(' '); + if (spaceIdx >= 0) { + // Get the old base style name from the beginning of the className. + var oldStyle = className.substring(0, spaceIdx); + + // Replace oldStyle with newStyle. We have to do this by hand because + // there is no String.replaceAll() and String.replace() takes a regex, + // which we can't guarantee is safe on arbitrary class names. + var newClassName = '', curIdx = 0; + while (true) { + var idx = className.indexOf(oldStyle, curIdx); + if (idx == -1) { + newClassName += className.substring(curIdx); + break; + } + + newClassName += className.substring(curIdx, idx); + newClassName += newStyle; + curIdx = idx + oldStyle.length; + } + + elem.className = newClassName; + } else { + // There was no space, and therefore only one class name, which we can + // simply clobber. + elem.className = newStyle; + } + }-*/; } +
diff --git a/user/test/com/google/gwt/user/client/ui/UIObjectTest.java b/user/test/com/google/gwt/user/client/ui/UIObjectTest.java new file mode 100644 index 0000000..febc9d7 --- /dev/null +++ b/user/test/com/google/gwt/user/client/ui/UIObjectTest.java
@@ -0,0 +1,180 @@ +/* + * Copyright 2007 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.junit.client.GWTTestCase; +import com.google.gwt.user.client.DOM; + +/** + * Tests UIObject. Currently, focuses on style name behaviors. + */ +public class UIObjectTest extends GWTTestCase { + + public String getModuleName() { + return "com.google.gwt.user.User"; + } + + static class MyObject extends UIObject { + MyObject() { + setElement(DOM.createDiv()); + } + } + + public void testEmpty() { + MyObject o = new MyObject(); + + assertEquals("gwt-nostyle", o.getStyleName()); + doStuff(o); + assertEquals("gwt-nostyle", o.getStyleName()); + } + + public void testNormal() { + // Test the basic set/get case. + MyObject o = new MyObject(); + + o.setStyleName("baseStyle"); + + assertEquals("baseStyle", o.getStyleName()); + doStuff(o); + assertEquals("baseStyle", o.getStyleName()); + } + + public void testAddStyleBeforeSet() { + MyObject o = new MyObject(); + + // Test that adding a style name before calling setStyleName() causes the + // gwt-nostyle class to get added. + o.addStyleName("userStyle"); + assertStartsWithClass(o, "gwt-nostyle"); + assertContainsClass(o, "userStyle"); + o.removeStyleName("userStyle"); + assertDoesNotContainClass(o, "userStyle"); + + // getStyleName() should still be "gwt-nostyle". + assertEquals("gwt-nostyle", o.getStyleName()); + + doStuff(o); + + assertStartsWithClass(o, "gwt-nostyle"); + assertEquals("gwt-nostyle", o.getStyleName()); + } + + public void testAddAndRemoveEmptyStyleName() { + MyObject o = new MyObject(); + + o.setStyleName("base"); + try { + o.addStyleName(""); + fail(); + } catch (IllegalArgumentException e) { + // This *should* throw. + } + + try { + o.addStyleName(" "); + fail(); + } catch (IllegalArgumentException e) { + // This *should* throw. + } + + try { + o.removeStyleName(""); + fail(); + } catch (IllegalArgumentException e) { + // This *should* throw. + } + + try { + o.removeStyleName(" "); + fail(); + } catch (IllegalArgumentException e) { + // This *should* throw. + } + + assertEquals("base", o.getStyleName()); + } + + public void testSetEmptyBaseStyleName() { + MyObject o = new MyObject(); + try { + o.setStyleName(""); + fail(); + } catch (IllegalArgumentException e) { + // This *should* throw. + } + + try { + o.setStyleName(" "); + fail(); + } catch (IllegalArgumentException e) { + // This *should* throw. + } + } + + public void testRemoveBaseStyleName() { + MyObject o = new MyObject(); + o.setStyleName("base"); + + try { + o.removeStyleName("base"); + fail(); + } catch (IllegalArgumentException e) { + // This *should* throw. + } + } + + // doStuff() should leave MyObject's style in the same state it started in. + private void doStuff(MyObject o) { + // Test that the base style remains the first class, and that the dependent + // style shows up. + o.addStyleName(o.getStyleName() + "-dependent"); + assertContainsClass(o, o.getStyleName() + "-dependent"); + + String oldBaseStyle = o.getStyleName(); + + // Test that replacing the base style name works (and doesn't munge up the + // user style). + o.addStyleName("userStyle"); + o.setStyleName("newBaseStyle"); + + assertEquals("newBaseStyle", o.getStyleName()); + assertStartsWithClass(o, "newBaseStyle"); + assertContainsClass(o, "newBaseStyle-dependent"); + assertContainsClass(o, "userStyle"); + assertDoesNotContainClass(o, oldBaseStyle); + assertDoesNotContainClass(o, oldBaseStyle + "-dependent"); + + // Clean up & return. + o.setStyleName(oldBaseStyle); + o.removeStyleName(oldBaseStyle + "-dependent"); + o.removeStyleName("userStyle"); + } + + private void assertContainsClass(UIObject o, String className) { + String attr = DOM.getElementProperty(o.getElement(), "className"); + assertTrue(attr.indexOf(className) != -1); + } + + private void assertDoesNotContainClass(UIObject o, String className) { + String attr = DOM.getElementProperty(o.getElement(), "className"); + assertTrue(attr.indexOf(className) == -1); + } + + private void assertStartsWithClass(UIObject o, String className) { + String attr = DOM.getElementProperty(o.getElement(), "className"); + assertTrue(attr.indexOf(className) == 0); + } +}