diff --git a/core/includes/common.inc b/core/includes/common.inc index 53a7453..5ff6167 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -1150,6 +1150,52 @@ function valid_url($url, $absolute = FALSE) { } /** + * Verifies that a number is a multiple of a given step. + * + * The implementation assumes it is dealing with IEEE 754 double precision + * floating point numbers that are used by PHP on most systems. + * + * This is based on the number/range verification methods of webkit. + * + * @param $value + * The value that needs to be checked. + * @param $step + * The step scale factor. Must be positive. + * @param $offset + * (optional) An offset, to which the difference must be a multiple of the + * given step. + * + * @return bool + * TRUE if no step mismatch has occured, or FALSE otherwise. + * + * @see http://opensource.apple.com/source/WebCore/WebCore-1298/html/NumberInputType.cpp + */ +function valid_number_step($value, $step, $offset = 0.0) { + $double_value = (double) abs($value - $offset); + + // The fractional part of a double has 53 bits. The greatest number that could + // be represented with that is 2^53. If the given value is even bigger than + // $step * 2^53, then dividing by $step will result in a very small remainder. + // Since that remainder can't even be represented with a single precision + // float the following computation of the remainder makes no sense and we can + // safely ignore it instead. + if ($double_value / pow(2.0, 53) > $step) { + return TRUE; + } + + // Now compute that remainder of a division by $step. + $remainder = (double) abs($double_value - $step * round($double_value / $step)); + + // $remainder is a double precision floating point number. Remainders that + // can't be represented with single precision floats are acceptable. The + // fractional part of a float has 24 bits. That means remainders smaller than + // $step * 2^-24 are acceptable. + $computed_acceptable_error = (double)($step / pow(2.0, 24)); + + return $computed_acceptable_error >= $remainder || $remainder >= ($step - $computed_acceptable_error); +} + +/** * @} End of "defgroup validation". */ @@ -6978,6 +7024,9 @@ function drupal_common_theme() { 'url' => array( 'render element' => 'element', ), + 'number' => array( + 'render element' => 'element', + ), 'form' => array( 'render element' => 'element', ), diff --git a/core/includes/form.inc b/core/includes/form.inc index 665a9e6..cb51e20 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -3890,6 +3890,69 @@ function theme_tel($variables) { } /** + * Returns HTML for a number form element. + * + * @param $variables + * An associative array containing: + * - element: An associative array containing the properties of the element. + * Properties used: #title, #value, #description, #size, #min, #max, + * #placeholder, #required, #attributes, #step. + * + * @ingroup themeable + */ +function theme_number($variables) { + $element = $variables['element']; + + $element['#attributes']['type'] = 'number'; + element_set_attributes($element, array('id', 'name', 'value', 'size', 'step', 'min', 'max', 'maxlength', 'placeholder')); + _form_set_class($element, array('form-number')); + + $output = ''; + + return $output; +} + +/** + * Form element validation handler for #type 'number'. + * + * Note that #required is validated by _form_validate() already. + */ +function form_validate_number(&$element, &$form_state) { + $value = $element['#value']; + if ($value === '') { + return; + } + + $name = empty($element['#title']) ? $element['#parents'][0] : $element['#title']; + + // Ensure the input is numeric. + if (!is_numeric($value)) { + form_error($element, t('%name must be a number.', array('%name' => $name))); + return; + } + + // Ensure that the input is greater than the #min property, if set. + if (isset($element['#min']) && $value < $element['#min']) { + form_error($element, t('%name must be higher or equal to %min.', array('%name' => $name, '%min' => $element['#min']))); + } + + // Ensure that the input is less than the #max property, if set. + if (isset($element['#max']) && $value > $element['#max']) { + form_error($element, t('%name must be below or equal to %max.', array('%name' => $name, '%max' => $element['#max']))); + } + + if (isset($element['#step']) && strtolower($element['#step']) != 'any') { + // Check that the input is an allowed multiple of #step (offset by #min if + // #min is set). + $offset = isset($element['#min']) ? $element['#min'] : 0.0; + + if (!valid_number_step($value, $element['#step'], $offset)) { + form_error($element, t('%name is not a valid number.', array('%name' => $name))); + } + } +} + +/** * Returns HTML for a url form element. * * @param $variables @@ -4322,16 +4385,6 @@ function element_validate_integer_positive($element, &$form_state) { } /** - * Form element validation handler for number elements. - */ -function element_validate_number($element, &$form_state) { - $value = $element['#value']; - if ($value != '' && !is_numeric($value)) { - form_error($element, t('%name must be a number.', array('%name' => $element['#title']))); - } -} - -/** * @} End of "defgroup form_api". */ diff --git a/core/modules/field/modules/number/number.module b/core/modules/field/modules/number/number.module index d00c55f..23725f5 100644 --- a/core/modules/field/modules/number/number.module +++ b/core/modules/field/modules/number/number.module @@ -98,14 +98,14 @@ function number_field_instance_settings_form($field, $instance) { '#title' => t('Minimum'), '#default_value' => $settings['min'], '#description' => t('The minimum value that should be allowed in this field. Leave blank for no minimum.'), - '#element_validate' => array('element_validate_number'), + '#element_validate' => array('form_validate_number'), ); $form['max'] = array( '#type' => 'textfield', '#title' => t('Maximum'), '#default_value' => $settings['max'], '#description' => t('The maximum value that should be allowed in this field. Leave blank for no maximum.'), - '#element_validate' => array('element_validate_number'), + '#element_validate' => array('form_validate_number'), ); $form['prefix'] = array( '#type' => 'textfield', diff --git a/core/modules/simpletest/drupal_web_test_case.php b/core/modules/simpletest/drupal_web_test_case.php index a2d85cb..0a313ef 100644 --- a/core/modules/simpletest/drupal_web_test_case.php +++ b/core/modules/simpletest/drupal_web_test_case.php @@ -2263,6 +2263,7 @@ class DrupalWebTestCase extends DrupalTestCase { case 'tel': case 'textarea': case 'url': + case 'number': case 'hidden': case 'password': case 'email': diff --git a/core/modules/simpletest/tests/common.test b/core/modules/simpletest/tests/common.test index 4f20361..12df48c 100644 --- a/core/modules/simpletest/tests/common.test +++ b/core/modules/simpletest/tests/common.test @@ -2007,6 +2007,73 @@ class CommonValidUrlUnitTestCase extends DrupalUnitTestCase { } /** + * Tests number step validation by valid_number_step(). + */ +class CommonValidNumberStepUnitTestCase extends DrupalUnitTestCase { + public static function getInfo() { + return array( + 'name' => 'Number step validation', + 'description' => 'Tests number step validation by valid_number_step()', + 'group' => 'Common', + ); + } + + /** + * Tests valid_number_step() without offset. + */ + function testNumberStep() { + // Value and step equal. + $this->assertTrue(valid_number_step(10.3, 10.3)); + + // Valid integer steps. + $this->assertTrue(valid_number_step(42, 21)); + $this->assertTrue(valid_number_step(42, 3)); + + // Valid float steps. + $this->assertTrue(valid_number_step(42, 10.5)); + $this->assertTrue(valid_number_step(1, 1/3)); + $this->assertTrue(valid_number_step(-100, 100/7)); + $this->assertTrue(valid_number_step(1000, -10)); + + // Valid and very small float steps. + $this->assertTrue(valid_number_step(1000.12345, 1e-10)); + $this->assertTrue(valid_number_step(3.9999999999999, 1e-13)); + + // Invalid integer steps. + $this->assertFalse(valid_number_step(100, 30)); + $this->assertFalse(valid_number_step(-10, 4)); + + // Invalid float steps. + $this->assertFalse(valid_number_step(6, 5/7)); + $this->assertFalse(valid_number_step(10.3, 10.25)); + + // Step mismatches very close to beeing valid. + $this->assertFalse(valid_number_step(70 + 9e-7, 10 + 9e-7)); + $this->assertFalse(valid_number_step(1936.5, 3e-8)); + } + + /** + * Tests valid_number_step() with offset. + */ + function testNumberStepOffset() { + // Try obvious fits. + $this->assertTrue(valid_number_step(11.3, 10.3, 1)); + $this->assertTrue(valid_number_step(100, 10, 50)); + $this->assertTrue(valid_number_step(-100, 90/7, -10)); + $this->assertTrue(valid_number_step(2/7 + 5/9, 1/7, 5/9)); + + // Ensure a small offset is still invalid. + $this->assertFalse(valid_number_step(10.3, 10.3, 0.0001)); + $this->assertFalse(valid_number_step(1/5, 1/7, 1/11)); + + // Try negative values and offsets. + $this->assertFalse(valid_number_step(1000, 10, -5)); + $this->assertFalse(valid_number_step(-10, 4, 0)); + $this->assertFalse(valid_number_step(-10, 4, -4)); + } +} + +/** * Tests writing of data records with drupal_write_record(). */ class CommonDrupalWriteRecordTestCase extends DrupalWebTestCase { diff --git a/core/modules/simpletest/tests/form.test b/core/modules/simpletest/tests/form.test index e79983c..6cc42d2 100644 --- a/core/modules/simpletest/tests/form.test +++ b/core/modules/simpletest/tests/form.test @@ -301,6 +301,69 @@ class FormsTestCase extends DrupalWebTestCase { } /** + * Tests validation of #type 'number' elements. + */ + function testNumber() { + $form = $form_state = array(); + $form = form_test_number($form, $form_state); + $this->drupalGet('form-test/number'); + + // Array with all the error messages to be checked. + $error_messages = array( + 'no_number' => '%name must be a number.', + 'too_low' => '%name must be higher or equal to %min.', + 'too_high' => '%name must be below or equal to %max.', + 'step_mismatch' => '%name is not a valid number.', + ); + + // The expected errors. + $expected = array( + 'integer_no_number' => 'no_number', + 'integer_no_step' => 0, + 'integer_no_step_step_error' => 'step_mismatch', + 'integer_step' => 0, + 'integer_step_error' => 'step_mismatch', + 'integer_step_min' => 0, + 'integer_step_min_error' => 'too_low', + 'integer_step_max' => 0, + 'integer_step_max_error' => 'too_high', + 'integer_step_min_border' => 0, + 'integer_step_max_border' => 0, + 'integer_step_based_on_min' => 0, + 'integer_step_based_on_min_error' => 'step_mismatch', + 'float_small_step' => 0, + 'float_step_no_error' => 0, + 'float_step_error' => 'step_mismatch', + 'float_step_hard_no_error' => 0, + 'float_step_hard_error' => 'step_mismatch', + 'float_step_any_no_error' => 0, + ); + + // Post form and show errors. + $this->drupalPost(NULL, array(), 'Submit'); + + foreach ($expected as $element => $error) { + // Create placeholder array. + $placeholders = array( + '%name' => $form[$element]['#title'], + '%min' => isset($form[$element]['#min']) ? $form[$element]['#min'] : '0', + '%max' => isset($form[$element]['#max']) ? $form[$element]['#max'] : '0', + ); + + foreach ($error_messages as $id => $message) { + // Check if the error exists on the page, if the current message ID is + // expected. Otherwise ensure that the error message is not present. + if ($id === $error) { + $this->assertRaw(format_string($message, $placeholders)); + } + else { + $this->assertNoRaw(format_string($message, $placeholders)); + } + } + } + } + + /** * Test handling of disabled elements. * * @see _form_test_disabled_elements() @@ -343,7 +406,7 @@ class FormsTestCase extends DrupalWebTestCase { // All the elements should be marked as disabled, including the ones below // the disabled container. - $this->assertEqual(count($disabled_elements), 37, 'The correct elements have the disabled property in the HTML code.'); + $this->assertEqual(count($disabled_elements), 38, 'The correct elements have the disabled property in the HTML code.'); $this->drupalPost(NULL, $edit, t('Submit')); $returned_values['hijacked'] = drupal_json_decode($this->content); diff --git a/core/modules/simpletest/tests/form_test.module b/core/modules/simpletest/tests/form_test.module index 3dcd247..dbf1c76 100644 --- a/core/modules/simpletest/tests/form_test.module +++ b/core/modules/simpletest/tests/form_test.module @@ -126,6 +126,12 @@ function form_test_menu() { 'page arguments' => array('form_test_placeholder_test'), 'access callback' => TRUE, ); + $items['form-test/number'] = array( + 'title' => 'Number', + 'page callback' => 'drupal_get_form', + 'page arguments' => array('form_test_number'), + 'access callback' => TRUE, + ); $items['form-test/checkboxes-radios'] = array( 'title' => t('Checkboxes, Radios'), 'page callback' => 'drupal_get_form', @@ -1096,10 +1102,125 @@ function form_test_select_submit($form, &$form_state) { } /** + * Builds a form to test #type 'number' validation. + */ +function form_test_number($form, &$form_state) { + $base = array( + '#type' => 'number', + ); + + $form['integer_no_number'] = $base + array( + '#title' => 'Integer test, #no_error', + '#default_value' => '#no_number', + ); + $form['integer_no_step'] = $base + array( + '#title' => 'Integer test without step', + '#default_value' => 5, + ); + $form['integer_no_step_step_error'] = $base + array( + '#title' => 'Integer test without step, #step_error', + '#default_value' => 4.5, + ); + $form['integer_step'] = $base + array( + '#title' => 'Integer test with step', + '#default_value' => 5, + '#step' => 1, + ); + $form['integer_step_error'] = $base + array( + '#title' => 'Integer test, with step, #step_error', + '#default_value' => 5, + '#step' => 2, + ); + $form['integer_step_min'] = $base + array( + '#title' => 'Integer test with min value', + '#default_value' => 5, + '#min' => 0, + '#step' => 1, + ); + $form['integer_step_min_error'] = $base + array( + '#title' => 'Integer test with min value, #min_error', + '#default_value' => 5, + '#min' => 6, + '#step' => 1, + ); + $form['integer_step_max'] = $base + array( + '#title' => 'Integer test with max value', + '#default_value' => 5, + '#max' => 6, + '#step' => 1, + ); + $form['integer_step_max_error'] = $base + array( + '#title' => 'Integer test with max value, #max_error', + '#default_value' => 5, + '#max' => 4, + '#step' => 1, + ); + $form['integer_step_min_border'] = $base + array( + '#title' => 'Integer test with min border check', + '#default_value' => -1, + '#min' => -1, + '#step' => 1, + ); + $form['integer_step_max_border'] = $base + array( + '#title' => 'Integer test with max border check', + '#default_value' => 5, + '#max' => 5, + '#step' => 1, + ); + $form['integer_step_based_on_min'] = $base + array( + '#title' => 'Integer test with step based on min check', + '#default_value' => 3, + '#min' => -1, + '#step' => 2, + ); + $form['integer_step_based_on_min_error'] = $base + array( + '#title' => 'Integer test with step based on min check, #step_error', + '#default_value' => 4, + '#min' => -1, + '#step' => 2, + ); + $form['float_small_step'] = $base + array( + '#title' => 'Float test with a small step', + '#default_value' => 4, + '#step' => 0.0000000000001, + ); + $form['float_step_no_error'] = $base + array( + '#title' => 'Float test', + '#default_value' => 1.2, + '#step' => 0.3, + ); + $form['float_step_error'] = $base + array( + '#title' => 'Float test, #step_error', + '#default_value' => 1.3, + '#step' => 0.3, + ); + $form['float_step_hard_no_error'] = $base + array( + '#title' => 'Float test hard', + '#default_value' => 0.9411764729088, + '#step' => 0.00392156863712, + ); + $form['float_step_hard_error'] = $base + array( + '#title' => 'Float test hard, #step_error', + '#default_value' => 0.9411764, + '#step' => 0.00392156863, + ); + $form['float_step_any_no_error'] = $base + array( + '#title' => 'Arbitrary float', + '#default_value' => 0.839562930284, + '#step' => 'aNy', + ); + $form['submit'] = array( + '#type' => 'submit', + '#value' => 'Submit', + ); + return $form; +} + +/** * Builds a form to test the placeholder attribute. */ function form_test_placeholder_test($form, &$form_state) { - foreach (array('textfield', 'textarea', 'url', 'password', 'search', 'tel', 'email') as $type) { + foreach (array('textfield', 'textarea', 'url', 'password', 'search', 'tel', 'email', 'number') as $type) { $form[$type] = array( '#type' => $type, '#title' => $type, @@ -1313,6 +1434,15 @@ function _form_test_disabled_elements($form, &$form_state) { '#disabled' => TRUE, ); + // Number. + $form['number'] = array( + '#type' => 'number', + '#title' => 'number', + '#disabled' => TRUE, + '#default_value' => 1, + '#test_hijack_value' => 2, + ); + // Date. $form['date'] = array( '#type' => 'date', diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 2b8eb8a..82013ce 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -403,6 +403,16 @@ function system_element_info() { '#theme' => 'search', '#theme_wrappers' => array('form_element'), ); + $types['number'] = array( + '#input' => TRUE, + '#size' => 30, + '#step' => 1, + '#maxlength' => 128, + '#process' => array('ajax_process_form'), + '#element_validate' => array('form_validate_number'), + '#theme' => 'number', + '#theme_wrappers' => array('form_element'), + ); $types['machine_name'] = array( '#input' => TRUE, '#default_value' => NULL, diff --git a/core/themes/bartik/css/style.css b/core/themes/bartik/css/style.css index ca37fae..7ab3d4e 100644 --- a/core/themes/bartik/css/style.css +++ b/core/themes/bartik/css/style.css @@ -1,3 +1,4 @@ + /* ---------- Overall Specifications ---------- */ body { @@ -1192,6 +1193,7 @@ input.form-tel, input.form-email, input.form-url, input.form-search, +input.form-number, textarea.form-textarea, select.form-select { border: 1px solid #ccc; diff --git a/core/themes/seven/style.css b/core/themes/seven/style.css index b298d1c..7ee4dad 100644 --- a/core/themes/seven/style.css +++ b/core/themes/seven/style.css @@ -1,3 +1,4 @@ + /** * Generic elements. */ @@ -604,6 +605,7 @@ div.teaser-checkbox .form-item, .form-disabled input.form-email, .form-disabled input.form-url, .form-disabled input.form-search, +.form-disabled input.form-number, .form-disabled input.form-file, .form-disabled textarea.form-textarea, .form-disabled select.form-select { @@ -694,6 +696,7 @@ input.form-tel, input.form-email, input.form-url, input.form-search, +input.form-number, input.form-file, textarea.form-textarea, select.form-select { @@ -711,6 +714,7 @@ input.form-tel:focus, input.form-email:focus, input.form-url:focus, input.form-search:focus, +input.form-number:focus, input.form-file:focus, textarea.form-textarea:focus, select.form-select:focus {