diff -rupN content.module content.module
--- content.module	2009-03-18 20:20:52.000000000 +0100
+++ content.module	2009-04-19 11:08:03.000000000 +0200
@@ -260,8 +260,8 @@ function content_load(&$node) {
  *
  */
 function content_validate(&$node, $form = NULL) {
-  _content_field_invoke('validate', $node, $form);
   _content_field_invoke_default('validate', $node, $form);
+  _content_field_invoke('validate', $node, $form);
 }
 
 /**
@@ -471,6 +471,15 @@ function content_add_more_submit_proxy($
 }
 
 /**
+ * Proxy function to call content_multiple_value_after_build(), because it might
+ * not be included yet when the form is processed and invokes the callback.
+ */
+function content_multiple_value_after_build_proxy($elements, &$form_state) {
+  module_load_include('inc', 'content', 'includes/content.node_form');
+  return content_multiple_value_after_build($elements, $form_state);
+}
+
+/**
  * Theme an individual form element.
  *
  * Combine multiple values into a table with drag-n-drop reordering.
@@ -490,8 +499,11 @@ function theme_content_multiple_values($
         'data' => t('!title: !required', array('!title' => $element['#title'], '!required' => $required)),
         'colspan' => 2
       ),
-      t('Order'),
+      array('data' => t('Order'), 'class' => 'content-multiple-weight-header'),
     );
+    if ($field['multiple'] == 1) {
+      $header[] = array('data' => '<span>'. t('Remove') .'</span>', 'class' => 'content-multiple-remove-header');
+    }
     $rows = array();
 
     // Sort items according to '_weight' (needed when the form comes back after
@@ -499,24 +511,31 @@ function theme_content_multiple_values($
     $items = array();
     foreach (element_children($element) as $key) {
       if ($key !== $element['#field_name'] .'_add_more') {
-        $items[] = &$element[$key];
+        $items[$element[$key]['#delta']] = &$element[$key];
       }
     }
-    usort($items, '_content_sort_items_value_helper');
+    uasort($items, '_content_sort_items_value_helper');
 
     // Add the items as table rows.
-    foreach ($items as $key => $item) {
+    foreach ($items as $delta => $item) {
       $item['_weight']['#attributes']['class'] = $order_class;
       $delta_element = drupal_render($item['_weight']);
+      if ($field['multiple'] == 1) {
+        $remove_element = drupal_render($item['_remove']);
+      }
       $cells = array(
         array('data' => '', 'class' => 'content-multiple-drag'),
         drupal_render($item),
         array('data' => $delta_element, 'class' => 'delta-order'),
       );
-      $rows[] = array(
-        'data' => $cells,
-        'class' => 'draggable',
-      );
+      $row_class = 'draggable';
+      if ($field['multiple'] == 1) {
+        if (!empty($item['_remove']['#default_value'])) {
+          $row_class .= ' content-multiple-removed-row';
+        }
+        $cells[] = array('data' => $remove_element, 'class' => 'content-multiple-remove-cell');
+      }
+      $rows[] = array('data' => $cells, 'class' => $row_class);
     }
 
     $output .= theme('table', $header, $rows, array('id' => $table_id, 'class' => 'content-multiple-table'));
@@ -524,6 +543,7 @@ function theme_content_multiple_values($
     $output .= drupal_render($element[$element['#field_name'] .'_add_more']);
 
     drupal_add_tabledrag($table_id, 'order', 'sibling', $order_class);
+    drupal_add_js(drupal_get_path('module', 'content') .'/js/content.node_form.js');
   }
   else {
     foreach (element_children($element) as $key) {
@@ -680,12 +700,13 @@ function content_associate_fields($modul
 function content_field($op, &$node, $field, &$items, $teaser, $page) {
   switch ($op) {
     case 'validate':
-      // TODO: here we could validate that the number of multiple data is correct ?
-      // We're controlling the number of fields to fill out and saving empty
-      // ones if a specified number is requested, so no reason to do any validation
-      // here right now, but if later create a method to indicate whether
-      // 'required' means all values must be filled out, we can come back
-      // here and check that they're not empty.
+      // If the field is configured for multiple values and these are handled
+      // by content module, we need to filter out items flagged for removal and
+      // count non-empty items to enforce field requirement settings.
+      if ($field['multiple'] >= 1 && content_handle('widget', 'multiple values', $field) == CONTENT_HANDLE_CORE) {
+        module_load_include('inc', 'content', 'includes/content.node_form');
+        content_multiple_value_nodeapi_validate($node, $field, $items, $teaser);
+      }
       break;
 
     case 'presave':
@@ -716,7 +737,7 @@ function content_field($op, &$node, $fie
         $items = _content_sort_items($field, $items);
       }
 
-      // Filter out empty values.
+      // Filter out empty values and items flagged for removal.
       $items = content_set_empty($field, $items);
 
       break;
@@ -724,14 +745,14 @@ function content_field($op, &$node, $fie
     case 'view':
       $addition = array();
       
-      // Previewed nodes bypass the 'presave' op, so we need to some massaging.
-      if ($node->build_mode == NODE_BUILD_PREVIEW && content_handle('widget', 'multiple values', $field) == CONTENT_HANDLE_CORE) {
+      // Previewed nodes bypass the 'presave' op, so we need to do some massaging.
+      if ($node->build_mode == NODE_BUILD_PREVIEW) {
         if (content_handle('widget', 'multiple values', $field) == CONTENT_HANDLE_CORE) {
           // Reorder items to account for drag-n-drop reordering.
           $items = _content_sort_items($field, $items);
         }
 
-        // Filter out empty values.
+        // Filter out empty values and items flagged for removal.
         $items = content_set_empty($field, $items);
       }
 
@@ -770,10 +791,10 @@ function content_field($op, &$node, $fie
         );
 
         // Fill-in items.
-        foreach ($items as $delta => $item) {
+        foreach (array_keys($items) as $weight => $delta) {
           $element['items'][$delta] = array(
-            '#item' => $item,
-            '#weight' => $delta,
+            '#item' => $items[$delta],
+            '#weight' => $weight,
           );
         }
 
@@ -882,7 +903,7 @@ function content_field($op, &$node, $fie
 }
 
 /**
- * Helper function to filter out empty values.
+ * Helper function to filter out empty values and items flagged for removal.
  *
  * On order to keep marker rows in the database, the function ensures
  * that the right number of 'all columns NULL' values is kept.
@@ -893,20 +914,22 @@ function content_field($op, &$node, $fie
  *   returns filtered and adjusted item array
  */
 function content_set_empty($field, $items) {
-  // Filter out empty values.
+  // Prepare an empty item.
+  $empty = array();
+  foreach (array_keys($field['columns']) as $column) {
+    $empty[$column] = NULL;
+  }
+
+  // Filter out items flagged for removal.
   $filtered = array();
   $function = $field['module'] .'_content_is_empty';
   foreach ((array) $items as $delta => $item) {
-    if (!$function($item, $field)) {
-      $filtered[] = $item;
+    if (empty($item['_remove'])) {
+      $filtered[] = ($function($item, $field) ? $empty : $item);
     }
   }
 
   // Make sure we store the right number of 'empty' values.
-  $empty = array();
-  foreach (array_keys($field['columns']) as $column) {
-    $empty[$column] = NULL;
-  }
   $pad = $field['multiple'] > 1 ? $field['multiple'] : 1;
   $filtered = array_pad($filtered, $pad, $empty);
 
@@ -921,7 +944,7 @@ function _content_sort_items($field, $it
   if ($field['multiple'] >= 1 && isset($items[0]['_weight'])) {
     usort($items, '_content_sort_items_helper');
     foreach ($items as $delta => $item) {
-      if (is_array($items[$delta])) {
+      if (is_array($item) && isset($item['_weight'])) {
         unset($items[$delta]['_weight']);
       }
     }
@@ -1002,7 +1025,14 @@ function content_storage($op, $node) {
             if (!isset($additions[$field_name])) {
               $additions[$field_name] = array();
             }
-            $additions[$field_name][] = $item;
+
+            // Preserve deltas when loading items from database.
+            if (isset($row['delta'])) {
+              $additions[$field_name][$row['delta']] = $item;
+            }
+            else {
+              $additions[$field_name][] = $item;
+            }
           }
         }
       }
Binary files images/remove.png and images/remove.png differ
diff -rupN includes/content.node_form.inc includes/content.node_form.inc
--- includes/content.node_form.inc	2009-02-10 23:53:04.000000000 +0100
+++ includes/content.node_form.inc	2009-04-19 12:43:13.000000000 +0200
@@ -151,21 +151,23 @@ function content_multiple_value_form(&$f
 
   switch ($field['multiple']) {
     case 0:
+      $deltas = array(0);
       $max = 0;
       break;
-    case 1:
-      $filled_items = content_set_empty($field, $items);
-      $current_item_count = isset($form_state['item_count'][$field_name])
-                            ? $form_state['item_count'][$field_name]
-                            : count($items);
-      // We always want at least one empty icon for the user to fill in.
-      $max = ($current_item_count > count($filled_items))
-              ? $current_item_count - 1
-              : $current_item_count;
 
+    case 1:
+      $deltas = array_keys($items);
+      $current_item_count = isset($form_state['item_count'][$field_name]) ? $form_state['item_count'][$field_name] : max(1, count($deltas));
+      $max = (!empty($deltas) ? max($deltas) : -1);
+      while (count($deltas) < $current_item_count) {
+        $max++;
+        $deltas[] = $max;
+      }
       break;
+
     default:
       $max = $field['multiple'] - 1;
+      $deltas = range(0, $max);
       break;
   }
 
@@ -180,12 +182,12 @@ function content_multiple_value_form(&$f
   );
   $function = $field['widget']['module'] .'_widget';
 
-  for ($delta = 0; $delta <= $max; $delta++) {
+  foreach ($deltas as $delta) {
     if ($element = $function($form, $form_state, $field, $items, $delta)) {
       $defaults = array(
         '#title' => ($field['multiple'] >= 1) ? '' : $title,
         '#description' => ($field['multiple'] >= 1) ? '' : $description,
-        '#required' => $delta == 0 && $field['required'],
+        '#required' => FALSE,
         '#weight' => $delta,
         '#delta' => $delta,
         '#columns' => array_keys($field['columns']),
@@ -206,10 +208,26 @@ function content_multiple_value_form(&$f
         );
       }
 
+      // Add a checkbox to allow users remove a single delta item.
+      // See content_set_empty() and theme_content_multiple_values().
+      if ($field['multiple'] == 1) {
+        // We name the element '_remove' to avoid clashing with column names
+        // defined by field modules.
+        $element['_remove'] = array(
+          '#type' => 'checkbox',
+          '#attributes' => array('class' => 'content-multiple-remove-checkbox'),
+          '#default_value' => isset($items[$delta]['_remove']) ? $items[$delta]['_remove'] : 0,
+        );
+      }
+
       $form_element[$delta] = array_merge($element, $defaults);
     }
   }
 
+  // Add an #after_build callback to prevent validation of fields that are
+  // flagged for removal and enforce field requirement settings.
+  $form_element['#after_build'] = array('content_multiple_value_after_build_proxy');
+
   // Add AHAH add more button, if not working with a programmed form.
   if ($field['multiple'] == 1 && empty($form['#programmed'])) {
     // Make sure the form is cached so ahah can work.
@@ -242,12 +260,128 @@ function content_multiple_value_form(&$f
     $form_element['#prefix'] = '<div id="'. $field_name_css .'-items">';
     $form_element['#suffix'] = '</div>';
     $form_element[$field_name .'_add_more']['#prefix'] = '<div class="content-add-more clear-block">';
-    $form_element[$field_name .'_add_more']['#suffix'] =  '</div>';
+    $form_element[$field_name .'_add_more']['#suffix'] = '</div>';
   }
   return $form_element;
 }
 
 /**
+ * After build callback for multiple value fields.
+ */
+function content_multiple_value_after_build($elements, &$form_state) {
+  $items_map = array();
+
+  foreach (element_children($elements) as $delta) {
+    // Find delta items for this field when the form if being processed for validation.
+    if (isset($elements[$delta]) && $elements[$delta] && is_numeric($delta) && !empty($elements[$delta]['#needs_validation'])) {
+
+      // Find items that have been flagged for removal.
+      if (isset($elements[$delta]['_remove']) && !empty($elements[$delta]['_remove']['#value'])) {
+
+        // Update the value in the #post attribute of the elements.
+        $post = &$elements[$delta]['#post'];
+        foreach ($elements[$delta]['#parents'] as $name) {
+          $post = &$post[$name];
+        }
+        $post = array('_weight' => $elements[$delta]['_weight']['#value'], '_remove' => 1);
+
+        // Alter the value of this element and children recursively.
+        content_multiple_value_after_build_recursive($elements[$delta], $elements[$delta]['#post']);
+
+        $items_map[$delta] = TRUE;
+      }
+      else {
+        $items_map[$delta] = FALSE;
+      }
+    }
+  }
+
+  // If the multiple values field is required, then make sure there's at
+  // least one item not flagged for removal. This is necessary to point
+  // the user to the correct form element when the validation the error
+  // is issued from content_multiple_value_nodeapi_validate().
+  $items_count = count($items_map);
+  if (!empty($elements['#required']) && $items_count > 0) {
+    // If the number of removed items is equal to the total number of
+    // items, then we'll reset the '_remove' flag of the first item, and
+    // that will be used to point the user when the required field error
+    // is issued by content_multiple_value_nodeapi_validate().
+    if ($items_count == count(array_filter($items_map))) {
+      $delta = key($items_map);
+      if (isset($elements[$delta]['_remove'])) {
+        $elements[$delta]['_remove']['#value'] = 0;
+      }
+    }
+  }
+
+  return $elements;
+}
+
+/**
+ * Helper function to deal with items flagged for removal recursively.
+ */
+function content_multiple_value_after_build_recursive(&$elements, $post) {
+  foreach (element_children($elements) as $key) {
+    if (isset($elements[$key]) && $elements[$key] && !in_array($key, array('_weight', '_remove', '_error_element'))) {
+      // Recurse through all children elements.
+      content_multiple_value_after_build_recursive($elements[$key], $post);
+    }
+  }
+
+  // Remove values for items flagged for removal.
+  if (isset($elements['#value'])) {
+    $elements['#value'] = NULL;
+    form_set_value($elements, NULL, $form_state);
+    $elements['#post'] = $post;
+  }
+}
+
+/**
+ * Implementation of nodeapi('validate') for multiple value fields
+ * managed by content module itself.
+ */
+function content_multiple_value_nodeapi_validate(&$node, $field, &$items, $form) {
+  // Get rid of the add more items element.
+  unset($items[$field['field_name'] .'_add_more']);
+
+  // Reorder items to account for drag-n-drop reordering.
+  $items = _content_sort_items($field, $items);
+
+  // Create a copy of the items before filtering those that are flagged
+  // for removal. We need this copy to obtain the error element.
+  $items_copy = $items;
+
+  // Filter out empty values and items flagged for removal.
+  $items = content_set_empty($field, $items);
+
+  // Enforce field requirement options.
+  if ($field['required']) {
+    // Count non-empty items.
+    $count = 0;
+    $function = $field['module'] .'_content_is_empty';
+    foreach ($items as $item) {
+      if (!$function($item, $field)) {
+        $count++;
+      }
+    }
+    // If the field is required, we expect at least one non-empty item.
+    if ($count == 0) {
+      // Try to guess the element path in the form from the first item that
+      // is not flagged for removal. Defaults to first item.
+      $error_element_index = 0;
+      foreach ($items_copy as $index => $item) {
+        if (empty($item['_remove'])) {
+          $error_element_index = $index;
+          break;
+        }
+      }
+      $error_element = isset($items_copy[$error_element_index]) && is_array($items_copy[$error_element_index]) && isset($items_copy[$error_element_index]['_error_element']) ? $items_copy[$error_element_index]['_error_element'] : '';
+      form_set_error($error_element, t('%name field is required.', array('%name' => t($field['widget']['label']))));
+    }
+  }
+}
+
+/**
  * Submit handler to add more choices to a content form. This handler is used when
  * JavaScript is not available. It makes changes to the form state and the
  * entire form is rebuilt during the page reload.
@@ -264,7 +398,6 @@ function content_add_more_submit($form, 
   }
 }
 
-
 /**
  * Menu callback for AHAH addition of new empty widgets.
  */
@@ -313,11 +446,13 @@ function content_add_more_js($type_name_
   unset($form_state['values'][$field_name][$field['field_name'] .'_add_more']);
   foreach ($_POST[$field_name] as $delta => $item) {
     $form_state['values'][$field_name][$delta]['_weight'] = $item['_weight'];
+    $form_state['values'][$field_name][$delta]['_remove'] = isset($item['_remove']) ? $item['_remove'] : 0;
   }
   $form_state['values'][$field_name] = _content_sort_items($field, $form_state['values'][$field_name]);
   $_POST[$field_name] = _content_sort_items($field, $_POST[$field_name]);
 
   // Build our new form element for the whole field, asking for one more element.
+  $delta = max(array_keys($_POST[$field_name])) + 1;
   $form_state['item_count'] = array($field_name => count($_POST[$field_name]) + 1);
   $form_element = content_field_form($form, $form_state, $field);
   // Let other modules alter it.
@@ -337,7 +472,6 @@ function content_add_more_js($type_name_
 
   // Build the new form against the incoming $_POST values so that we can
   // render the new element.
-  $delta = max(array_keys($_POST[$field_name])) + 1;
   $_POST[$field_name][$delta]['_weight'] = $delta;
   $form_state = array('submitted' => FALSE);
   $form += array(
diff -rupN js/content.node_form.js js/content.node_form.js
--- js/content.node_form.js	1970-01-01 01:00:00.000000000 +0100
+++ js/content.node_form.js	2009-04-18 15:36:08.000000000 +0200
@@ -0,0 +1,254 @@
+// $Id$
+
+/**
+ * Private namespace for local methods.
+ */
+Drupal.contentRemoveButtons = Drupal.contentRemoveButtons || { animating: false };
+
+/**
+ * Manipulation of content remove buttons.
+ *
+ * TableDrag objects for multiple value fields (and multigroups) are scanned
+ * to find 'remove' checkboxes. These checkboxes are hidden when javascript is
+ * enabled (using the Global CSS Killswitch, html.js, defined in drupal.js).
+ * A new 'remove' button is created here in place of these checkboxes aimed to
+ * provide a more user-friendly method to remove items.
+ */
+Drupal.behaviors.contentRemoveButtons = function(context) {
+  var self = Drupal.contentRemoveButtons;
+
+  $('table.content-multiple-table', context).not('.content-multiple-remove-buttons-processed').addClass('content-multiple-remove-buttons-processed').each(function() {
+    var table = this, tableDrag = Drupal.tableDrag[$(table).attr('id')];
+
+    // Replace remove checkboxes with buttons.
+    $('input.content-multiple-remove-checkbox', table).each(function() {
+      var $checkbox = $(this), $row = $checkbox.parents('tr:first');
+      var isRemoved = $checkbox.attr('checked');
+      var $button = $(Drupal.theme('contentRemoveButton', tableDrag.getRemoveButtonTitle(isRemoved)));
+
+      // Bind the onClick event to the remove button.
+      $button.bind('click', function(event) {
+        self.onClick($button, $checkbox, $row, tableDrag);
+        return false;
+      });
+
+      // Attach the new button to the DOM tree.
+      $checkbox.parent().append($button);
+
+      // If the row is removed, then hide the contents of the cells and show
+      // the removed warning on the cell next to the drag'n'drop cell.
+      if (isRemoved) {
+        self.getCellWrappers($row).hide();
+        self.showRemovedWarning($row, tableDrag);
+
+        // FAPI not rendering the form on errors - case #1:
+        // If the form has been submitted and any error was found, FAPI will
+        // send back the same exact form that was submitted to show the error
+        // messages, but it will not invoke the rendering engine which is where
+        // we actually assign the removed class to the row, so we need to check
+        // this situation here and add the class if it is not present.
+        if (!$row.hasClass('content-multiple-removed-row')) {
+          $row.addClass('content-multiple-removed-row');
+        }
+      }
+      else {
+        // FAPI not rendering the form on errors - case #2:
+        // Similar issue than #1, but this time caused when user removes an
+        // item, previews, FAPI renders the new form with the removed class,
+        // then user changes anything in the form that causes an error, and
+        // also restores the previously removed item. This time, FAPI will
+        // send the form validation error with the item not flagged for removal
+        // but having the removed class that was present when the form was
+        // rendered in the previous step. So we need to remove this class here,
+        // if present, since the item is not really flagged for removal.
+        if ($row.hasClass('content-multiple-removed-row')) {
+          $row.removeClass('content-multiple-removed-row');
+        }
+      }
+    });
+  });
+};
+
+/**
+ * onClick handler for remove buttons.
+ *
+ * @param $button
+ *   The jQuery object of the remove button.
+ * @param $checkbox
+ *   The jQuery object of the remove checkbox.
+ * @param $row
+ *   The jQuery object of the table row.
+ * @param tableDrag
+ *   The tableDrag object where the row is.
+ */
+Drupal.contentRemoveButtons.onClick = function($button, $checkbox, $row, tableDrag) {
+  var self = Drupal.contentRemoveButtons;
+
+  // Prevent the user from firing this event while another one is still being
+  // processed. This flag is (should be) restored at end of animations.
+  // Note that this technique is required because the browser may experience
+  // delays while performing the animation, for whatever reason, and if this
+  // process it fired more than once at the same time for the same row, then
+  // it may cause unexpected behavior because the state of the elements being
+  // manipulated would be unknown.
+  if (self.animating) {
+    return;
+  }
+  self.animating = true;
+
+  // Toggle the state of the checkbox.
+  var isRemoved = !$checkbox.attr('checked');
+  $checkbox.attr('checked', isRemoved);
+
+  // Toggle the row class.
+  if (isRemoved) {
+    $row.addClass('content-multiple-removed-row');
+  }
+  else {
+    $row.removeClass('content-multiple-removed-row');
+  }
+
+  // Toggle the button title.
+  $button.attr('title', tableDrag.getRemoveButtonTitle(isRemoved));
+
+  // Get the list of cell wrappers in this row.
+  var $cellWrappers = self.getCellWrappers($row);
+
+  // If for whatever reason this row doesn't have cells with elements,
+  // then we are done, but we still need to reset the global busy flag
+  // and display the tableDrag changed warning.
+  if (!$cellWrappers.size()) {
+    tableDrag.displayChangedWarning();
+    self.animating = false;
+    return;
+  }
+
+  // Toggle the visible state of the row cells.
+  $cellWrappers.each(function() {
+    var $cellWrapper = $(this);
+
+    // Drop the removed warning during restore operation.
+    if (!isRemoved) {
+      self.hideRemovedWarning($row);
+    }
+
+    // Toggle the visibility state of the contents of cells.
+    $cellWrapper.animate({opacity: (isRemoved ? 'hide' : 'show')}, 'fast', function() {
+      var $cell = $cellWrapper.parent();
+
+      // Show the removed warning during remove operation.
+      if (isRemoved && $cell.prev(':first').hasClass('content-multiple-drag')) {
+        self.showRemovedWarning($row, tableDrag);
+      }
+
+      // Disable the busy flag when animation of last cell has finished.
+      if ($cell.next(':first').hasClass('delta-order')) {
+        tableDrag.displayChangedWarning();
+        self.animating = false;
+      }
+    });
+  });
+};
+
+/**
+ * Show the removed warning on the given row.
+ *
+ * @param $row
+ *   The jQuery object of the table row.
+ * @param tableDrag
+ *   The tableDrag object where the row is.
+ */
+Drupal.contentRemoveButtons.showRemovedWarning = function($row, tableDrag) {
+  $('.content-multiple-drag', $row).next(':first').append(Drupal.theme('contentRemovedWarning', tableDrag.getRemovedWarning()));
+};
+
+/**
+ * Hide the removed warning from the given row.
+ *
+ * @param $row
+ *   The jQuery object of the table row.
+ */
+Drupal.contentRemoveButtons.hideRemovedWarning = function($row) {
+  if ($('.content-multiple-removed-warning', $row).size()) {
+    $('.content-multiple-removed-warning', $row).remove();
+  }
+};
+
+/**
+ * Get cell wrappers for the given row.
+ *
+ * @param $row
+ *   The jQuery object of the table row.
+ */
+Drupal.contentRemoveButtons.getCellWrappers = function($row) {
+  // Create cell wrappers if this row has not already been processed.
+  if (!$('.content-multiple-cell-content-wrapper', $row).size()) {
+    // Wrap the contents of all cells (except the drag'n'drop, weight and
+    // remove button cells) with a dummy block element. This operation makes
+    // animations faster because we just need to show/hide a single element
+    // per cell, and it also prevents from creating more than one warning
+    // element per row.
+    $row.children('td:not(.content-multiple-drag):not(.delta-order):not(.content-multiple-remove-cell)').each(function() {
+      var $cell = $(this);
+      $cell.wrapInner('<div class="content-multiple-cell-content-wrapper"/>');
+    });
+  }
+  return $('.content-multiple-cell-content-wrapper', $row);
+};
+
+/**
+ * Display table change warning when appropriate.
+ */
+Drupal.tableDrag.prototype.displayChangedWarning = function() {
+  if (this.changed == false) {
+    $(Drupal.theme('tableDragChangedWarning')).insertAfter(this.table).hide().fadeIn('slow');
+    this.changed = true;
+  }
+};
+
+/**
+ * Get the title of the remove button.
+ *
+ * This method is an extension of the tableDrag object. This means a separate
+ * module can override this method for a particular tableDrag object. For example,
+ * the multigroup module can change the text to read 'Remove this group of items',
+ * another module could change it to 'Remove this image', and so on...
+ * To override this function:
+ *
+ * @code
+ *  var tableId = $(table).attr('id');
+ *  Drupal.tableDrag[tableId].getRemoveButtonTitle = function(isRemoved) {
+ *    return (isRemoved ? Drupal.t('Restore this foo') : Drupal.t('Remove this foo'));
+ *  };
+ * @endcode
+ *
+ * @param isRemoved
+ *   A flag that indicates the state of the button.
+ */
+Drupal.tableDrag.prototype.getRemoveButtonTitle = function(isRemoved) {
+  return (isRemoved ? Drupal.t('Restore this item') : Drupal.t('Remove this item'));
+};
+
+/**
+ * Get the item removed warning.
+ *
+ * This method is an extension of the tableDrag object. It can be overridden by
+ * a separate module. See getRemoveButtonTitle() for further information.
+ */
+Drupal.tableDrag.prototype.getRemovedWarning = function() {
+  return Drupal.t('Removed');
+};
+
+/**
+ * Theme the remove button.
+ */
+Drupal.theme.prototype.contentRemoveButton = function(title) {
+  return '<a href="javascript:void(0)" class="content-multiple-remove-button" title="'+ title +'"></a>';
+};
+
+/**
+ * Theme the item removed warning.
+ */
+Drupal.theme.prototype.contentRemovedWarning = function(warning) {
+  return '<div class="content-multiple-removed-warning">'+ warning +'</div>';
+};
diff -rupN tests/content.crud.test tests/content.crud.test
--- tests/content.crud.test	2008-12-08 13:41:08.000000000 +0100
+++ tests/content.crud.test	2009-04-01 16:27:24.000000000 +0200
@@ -1234,3 +1234,70 @@ class ContentOptionWidgetTest extends Co
 
 }
 
+class ContentEmptyDeltaTest extends ContentCrudTestCase {
+  function getInfo() {
+    return array(
+      'name' => t('Empty deltas'),
+      'description' => t('Test leaving empty values on a multivalue field and then removing them.'),
+      'group' => t('CCK'),
+    );
+  }
+
+  function setUp() {
+    parent::setUp();
+    $this->loginWithPermissions();
+    $this->acquireContentTypes(1);
+  }
+
+  function testEmptyTextField() {
+    // Create a content type with a multivalue text field.
+    $type = $this->content_types[0];
+    $type_url = str_replace('_', '-', $type->type);
+    $value1 = $this->randomName(5);
+    $value2 = $this->randomName(5);
+    $value3 = $this->randomName(5);
+    $field = $this->createFieldText(array('text_processing' => 0, 'multiple' => 1));
+    $field_name = $field['field_name'];
+
+    // Create a node with three values set.
+    $edit = array(
+      'title' => $this->randomName(20),
+      'body' => $this->randomName(20),
+      'type' => $type->name,
+    );
+    $edit[$field_name][0]['value'] = $value1;
+    $edit[$field_name][1]['value'] = $value2;
+    $edit[$field_name][2]['value'] = $value3;
+    $node = $this->drupalCreateNode($edit);
+    $max_delta = max(array_keys($node->{$field_name}));
+    $this->assertEqual($max_delta, 2, 'Three values saved, highest delta is 2');
+    $this->drupalGet('node/'. $node->nid);
+    $this->assertText($value1, 'First value displayed');
+    $this->assertText($value2, 'Second value displayed');
+    $this->assertText($value3, 'Third value displayed');
+
+    // Set second value to an empty string.
+    $node->{$field_name}[1]['value'] = '';
+    node_save($node);
+    $node = node_load($node->nid, NULL, TRUE);
+    $this->assertIdentical($node->{$field_name}[1]['value'], NULL, 'Second value is empty');
+    $max_delta = max(array_keys($node->{$field_name}));
+    $this->assertEqual($max_delta, 2, 'Three values saved, highest delta is 2');
+    $this->drupalGet('node/'. $node->nid);
+    $this->assertText($value1, 'First value displayed');
+    $this->assertNoText($value2, 'Second value not displayed');
+    $this->assertText($value3, 'Third value displayed');
+
+    // Remove the second value.
+    $node->{$field_name}[1]['_remove'] = 1;
+    node_save($node);
+    $node = node_load($node->nid, NULL, TRUE);
+    $this->assertEqual($node->{$field_name}[1]['value'], $value3, 'Third value has moved to delta 1');
+    $max_delta = max(array_keys($node->{$field_name}));
+    $this->assertEqual($max_delta, 1, 'Two values saved, highest delta is 1');
+    $this->drupalGet('node/'. $node->nid);
+    $this->assertText($value1, 'First value displayed');
+    $this->assertNoText($value2, 'Second value not displayed');
+    $this->assertText($value3, 'Third value displayed');
+  }
+}
diff -rupN theme/content-module.css theme/content-module.css
--- theme/content-module.css	2009-03-14 19:45:38.000000000 +0100
+++ theme/content-module.css	2009-04-18 10:48:57.000000000 +0200
@@ -27,6 +27,42 @@
   margin:0;
 }
 
+.content-multiple-remove-button {
+  display: block;
+  float: right;
+  height: 14px;
+  width: 16px;
+  margin: 2px 0 1px 0;
+  padding: 0;
+  background:transparent url(../images/remove.png) no-repeat 0 0;
+  border-bottom: #C2C9CE 1px solid;
+  border-right: #C2C9CE 1px solid;
+}
+.content-multiple-remove-button:hover {
+  background-position: 0 -14px;
+}
+.content-multiple-removed-row .content-multiple-remove-button {
+  background-position: 0 -28px;
+}
+.content-multiple-removed-row .content-multiple-remove-button:hover {
+  background-position: 0 -42px;
+}
+html.js .content-multiple-removed-row {
+  background-color: #ffffcc;
+}
+.content-multiple-weight-header,
+.content-multiple-remove-header,
+.content-multiple-remove-cell,
+.content-multiple-table td.delta-order {
+  text-align: center;
+}
+html.js .content-multiple-weight-header,
+html.js .content-multiple-remove-header span,
+html.js .content-multiple-table td.delta-order,
+html.js .content-multiple-remove-checkbox {
+  display: none;
+}
+
 .node-form .number {
   display:inline;
   width:auto;
