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);

+  }

+}