diff --git includes/common.inc includes/common.inc index 1454cf0..0d236e3 100644 --- includes/common.inc +++ includes/common.inc @@ -4122,6 +4122,12 @@ function drupal_render(&$elements) { isset($elements['#attached_css']) ? $elements['#attached_css'] : array() ); + // Add the dependency information for this form element. + if (!empty($elements['#dependencies'])) { + drupal_add_js('misc/dependencies.js', array('weight' => JS_LIBRARY + 1)); + drupal_add_js(array('dependencies' => array('#' . $elements['#id'] => $elements['#dependencies'])), 'setting'); + } + $prefix = isset($elements['#prefix']) ? $elements['#prefix'] : ''; $suffix = isset($elements['#suffix']) ? $elements['#suffix'] : ''; @@ -4648,6 +4654,9 @@ function drupal_common_theme() { 'vertical_tabs' => array( 'arguments' => array('element' => NULL), ), + 'wrapper' => array( + 'arguments' => array('element' => NULL), + ), ); } diff --git includes/form.inc includes/form.inc index 64ac3e6..13d3bea 100644 --- includes/form.inc +++ includes/form.inc @@ -2339,6 +2339,38 @@ function theme_vertical_tabs($element) { } /** + * Processes a wrapper element. + * + * @param $element + * An associative array containing the properties and children of the + * wrapper. + * @param $form_state + * The $form_state array for the form this element belongs to. + * @return + * The processed element. + */ +function form_process_wrapper($element, &$form_state) { + $element['#id'] = form_clean_id(implode('-', $element['#parents']) . '-wrapper'); + return $element; +} + +/** + * Adds a wrapper for grouping items + * + * @param $element + * An associative array containing the properties and children of the + * group. + * Properties used: #children. + * @return + * A themed HTML string representing the form element. + * + * @ingroup themeable + */ +function theme_wrapper($element) { + return '
' . $element['#children'] . '
'; +} + +/** * Theme a submit button form element. * * @param $element diff --git misc/collapse.js misc/collapse.js index 4e9f9ca..44ce50c 100644 --- misc/collapse.js +++ misc/collapse.js @@ -9,7 +9,9 @@ Drupal.toggleFieldset = function (fieldset) { // Action div containers are processed separately because of a IE bug // that alters the default submit button behavior. var content = $('> div:not(.action)', fieldset); - $(fieldset).removeClass('collapsed'); + $(fieldset) + .removeClass('collapsed') + .trigger({ type: 'collapsed', value: false }); content.hide(); content.slideDown({ duration: 'fast', @@ -27,6 +29,7 @@ Drupal.toggleFieldset = function (fieldset) { } else { $('div.action', fieldset).hide(); + $(fieldset).trigger({ type: 'collapsed', value: true }); var content = $('> div:not(.action)', fieldset).slideUp('fast', function () { $(this.parentNode).addClass('collapsed'); this.parentNode.animating = false; diff --git misc/dependencies.js misc/dependencies.js new file mode 100644 index 0000000..bfd6b87 --- /dev/null +++ misc/dependencies.js @@ -0,0 +1,387 @@ +// $Id$ +(function ($) { + +var dependencies = Drupal.dependencies = {}; + +// Change `false` to `true` to enable debug information. +dependencies.debug = 'console' in window && false; + +// An array of functions that should be postponed. +dependencies.postponed = []; + +/** + * Attaches the dependencies. + */ +Drupal.behaviors.dependencies = { + attach: function (context, settings) { + for (var selector in settings.dependencies) { + for (var state in settings.dependencies[selector]) { + new dependencies.Dependant({ + element: $(selector), + state: dependencies.State.sanitize(state), + dependees: settings.dependencies[selector][state] + }); + } + } + + // Execute all postponed functions now. + while (dependencies.postponed.length) { + (dependencies.postponed.shift())(); + } + } +}; + +/** + * An object representing a an element that depends on other elements. + * + * @param args + * An object with the following keys (all of which are required): + * - element: A jQuery object of the dependant element + * - state: A State object describing the state that is dependant + * - dependees: An object with dependency specifications. Lists all elements + * that this element depends on. + */ +dependencies.Dependant = function (args) { + $.extend(this, { values: {}, oldValue: undefined }, args); + + for (var selector in this.dependees) { + this.initializeDependee(selector, this.dependees[selector]); + } +}; + +// Comparison functions for comparing the value of an element with the +// specification from the dependency settings. If the object type can't be +// found in this list, the === operator is used by default. +dependencies.Dependant.comparisons = { + 'RegExp': function (reference, value) { + return reference.test(value); + }, + 'Function': function (reference, value) { + // The `reference` variable is a comparison function. + return reference(value); + } +}; + +dependencies.Dependant.prototype = { + /** + * Initializes one of the elements this dependant depends on. + * + * @param selector + * The CSS selector describing the dependee. + * @param states + * The list of states that have to be monitored for tracking the + * dependee's compliance status. + */ + initializeDependee: function (selector, states) { + var self = this; + + // Cache for the states of this dependee. + self.values[selector] = {}; + + $.each(states, function (state, value) { + state = dependencies.State.sanitize(state); + + // Initialize the value of this state. + self.values[selector][state.pristine] = undefined; + + // Monitor state changes of the specified state for this dependee. + $(selector).bind('state:' + state, function (e) { + var complies = self.compare(value, e.value); + self.update(selector, state, complies); + }); + + // Make sure the event we just bound ourselves to is actually fired. + new dependencies.Trigger({ selector: selector, state: state }); + }); + }, + + /** + * Compares a value with a reference value. + * + * @param reference + * The value used for reference. + * @param value + * The value to compare with the reference value. + * @return + * `true`, `undefined` or `false`. + */ + compare: function (reference, value) { + if (reference.constructor.name in dependencies.Dependant.comparisons) { + // Use a custom compare function for certain reference value types. + return dependencies.Dependant.comparisons[reference.constructor.name](reference, value); + } + else { + // Do a plain comparison otherwise. + return compare(reference, value); + } + }, + + /** + * Update the value of a dependee's state. + * + * @param selector + * CSS selector describing the dependee. + * @param state + * A State object describing the dependee's updated state. + * @param value + * The new value for the dependee's updated state. + */ + update: function (selector, state, value) { + // Only act when the 'new' value is actually new. + if (value !== this.values[selector][state.pristine]) { + this.values[selector][state.pristine] = value; + this.reevaluate(); + } + }, + + /** + * Triggers change events in case a state changed. + */ + reevaluate: function () { + var value = undefined; + + // Merge all individual values to find out whether this dependee complies. + for (var selector in this.values) { + for (var state in this.values[selector]) { + state = dependencies.State.sanitize(state); + var complies = this.values[selector][state.pristine]; + value = ternary(value, invert(complies, state.invert)); + } + } + + // Only invoke a state change event when the value actually changed. + if (value !== this.oldValue) { + // Store the new value so that we can compare later whether the value + // actually changed. + this.oldValue = value; + + // Normalize the value to match the normalized state name. + value = invert(value, this.state.invert); + + // By adding `trigger: true`, we ensure that state changes don't go into + // infinite loops. + this.element.trigger({ type: 'state:' + this.state, value: value, trigger: true }); + } + } +}; + + + +dependencies.Trigger = function (args) { + $.extend(this, args); + + if (this.state in dependencies.Trigger.states) { + this.element = $(this.selector); + + // Only call the trigger initializer when it wasn't yet attached to this + // element. Otherwise we'd end up with duplicate events. + if (!this.element.data('trigger:' + this.state)) { + this.initialize(); + } + } +}; + +dependencies.Trigger.prototype = { + initialize: function () { + var self = this; + var trigger = dependencies.Trigger.states[this.state]; + + if (typeof trigger == 'function') { + // We have a custom trigger initialization function. + trigger.call(window, this.element); + } + else { + $.each(trigger, function (event, valueFn) { + self.defaultTrigger(event, valueFn); + }); + } + + // Mark this trigger as initialized for this element. + this.element.data('trigger:' + this.state, true); + }, + + defaultTrigger: function (event, valueFn) { + var self = this; + var oldValue = valueFn.call(this.element); + + // Attach the event callback. + this.element.bind(event, function (e) { + var value = valueFn.call(self.element, e); + // Only trigger the event if the value has actually changed. + if (oldValue !== value) { + if (dependencies.debug) console.log('firing %o with value %o for %o', 'state:' + self.state, value, this); + self.element.trigger({ type: 'state:' + self.state, value: value, oldValue: oldValue }); + oldValue = value; + } + }); + + dependencies.postponed.push(function () { + // Trigger the event once for initialization purposes. + self.element.trigger({ type: 'state:' + self.state, value: oldValue, oldValue: undefined }); + }); + } +}; + +// This list of states contains functions that are used to monitor the state +// of an element. Whenever an element depends on the state of another element, +// one of these trigger functions is added to the dependee so that the +// dependant element can be updated. +dependencies.Trigger.states = { + // 'empty' describes the state to be monitored + empty: { + // 'keyup' is the (native DOM) event that we watch for. + 'keyup': function () { + // The function associated to that trigger returns the new value for the + // state. + return this.val() == ''; + } + }, + + checked: { + 'change': function () { + return this.attr('checked'); + } + }, + + value: { + 'keyup': function () { + return this.val(); + } + }, + + collapsed: { + 'collapsed': function(e) { + return (e !== undefined && 'value' in e) ? e.value : this.is('.collapsed'); + } + } +}; + + +/** + * A state object is used for describing the state and performing aliasing. + */ +dependencies.State = function(state) { + // We may need the original unresolved name later. + this.pristine = this.name = state; + + // Normalize the state name. + while(true) { + // Iteratively remove exclamation marks and invert the value. + while (this.name.charAt(0) == '!') { + this.name = this.name.substring(1); + this.invert = !this.invert; + } + + // Replace the state with its normalized name. + if (this.name in dependencies.State.aliases) + this.name = dependencies.State.aliases[this.name]; + else + break; + } +}; + +/** + * Create a new State object by sanitizing the passed value. + */ +dependencies.State.sanitize = function (state) { + if (state instanceof dependencies.State) { + return state; + } + else { + return new dependencies.State(state); + } +}; + +// This list of aliases is used to normalize states and associates negated +// names with their respective inverse state. +dependencies.State.aliases = { + 'enabled': '!disabled', + 'invisible': '!visible', + 'invalid': '!valid', + 'untouched': '!touched', + 'optional': '!required', + 'filled': '!empty', + 'unchecked': '!checked', + 'irrelevant': '!relevant', + 'expanded': '!collapsed', + 'readwrite': '!readonly' +}; + +// dependencies.State: prototype +dependencies.State.prototype = { + invert: false, + + /** + * Ensures that just using the state object returns the name. + */ + toString: function() { + return this.name; + } +}; + +// Global state change handlers. These are bound to `document` to cover all +// elements whose state changes. Events sent to elements within the page +// bubble up to these handlers. We use this system so that themes and modules +// can override these state change handlers for particular parts of a page. +{ + $(document).bind('state:disabled', function(e) { + // Only act when this change was triggered by a dependency and not by the + // element monitoring itself. + if (e.trigger) { + $(e.target) + .attr('disabled', e.value) + .filter('.form-element') + .closest('.form-item, .form-wrapper')[e.value ? 'addClass' : 'removeClass']('form-disabled'); + + // Note: WebKit nightlies don't reflect that change correctly. + // See https://bugs.webkit.org/show_bug.cgi?id=23789 + } + }); + + $(document).bind('state:required', function(e) { + if (e.trigger) { + $(e.target).closest('.form-item, .form-wrapper')[e.value ? 'addClass' : 'removeClass']('form-required'); + } + }); + + $(document).bind('state:visible', function(e) { + if (e.trigger) { + $(e.target).closest('.form-item, .form-wrapper')[e.value ? 'show' : 'hide'](); + } + }); + + $(document).bind('state:checked', function(e) { + if (e.trigger) { + $(e.target).attr('checked', e.value); + } + }); + + $(document).bind('state:collapsed', function(e) { + if (e.trigger) { + if ($(e.target).is('.collapsed') !== e.value) { + $('> legend a', e.target).click(); + } + } + }); +} + +// These are helper functions implementing addition "operators" and don't +// implement any logic that is particular to Dependencies. +{ + // Bitwise AND with a third undefined state. + function ternary (a, b) { + return a === undefined ? b : (b === undefined ? a : a && b); + }; + + // Inverts a (if it's not undefined) when invert is true. + function invert (a, invert) { + return (invert && a !== undefined) ? !a : a; + }; + + // Compares two values while ignoring undefined values. + function compare (a, b) { + return (a === b) ? (a === undefined ? a : true) : (a === undefined || b === undefined); + } +} + +})(jQuery); diff --git modules/menu/menu.module modules/menu/menu.module index ef0ab5a..07c3801 100644 --- modules/menu/menu.module +++ modules/menu/menu.module @@ -435,11 +435,21 @@ function menu_form_alter(&$form, $form_state, $form_id) { } $form['menu']['#item'] = $item; + $form['menu']['has_menu'] = array( + '#type' => 'checkbox', + '#title' => t('Add menu item'), + '#description' => t('Check if you want to assign a menu item to this content.'), + '#default_value' => !empty($item['link_title']), + ); + $form['menu']['link_title'] = array('#type' => 'textfield', '#title' => t('Menu link title'), '#default_value' => $item['link_title'], '#description' => t('The link text corresponding to this item that should appear in the menu. Leave blank if you do not wish to add this post to the menu.'), '#required' => FALSE, + '#dependencies' => array( + 'enabled' => array('input[name="menu[has_menu]"]' => array('checked' => TRUE)) + ), ); // Generate a list of possible parents (not including this item or descendants). $options = menu_parent_options(menu_get_menus(), $item); @@ -454,6 +464,9 @@ function menu_form_alter(&$form, $form_state, $form_id) { '#options' => $options, '#description' => t('The maximum depth for an item and all its children is fixed at !maxdepth. Some menu items may not be available as parents if selecting them would exceed this limit.', array('!maxdepth' => MENU_MAX_DEPTH)), '#attributes' => array('class' => array('menu-title-select')), + '#dependencies' => array( + 'enabled' => array('input[name="menu[has_menu]"]' => array('checked' => TRUE)) + ), ); $form['#submit'][] = 'menu_node_form_submit'; @@ -463,6 +476,9 @@ function menu_form_alter(&$form, $form_state, $form_id) { '#delta' => 50, '#default_value' => $item['weight'], '#description' => t('Optional. In the menu, the heavier items will sink and the lighter items will be positioned nearer the top.'), + '#dependencies' => array( + 'enabled' => array('input[name="menu[has_menu]"]' => array('checked' => TRUE)) + ), ); } } diff --git modules/node/node.pages.inc modules/node/node.pages.inc index 2f7b30c..4008ea4 100644 --- modules/node/node.pages.inc +++ modules/node/node.pages.inc @@ -180,6 +180,9 @@ function node_form(&$form_state, $node) { '#type' => 'checkbox', '#title' => t('Create new revision'), '#default_value' => $node->revision, + '#dependencies' => array( + 'checked' => array('textarea[name="log"]' => array('empty' => FALSE)) + ), ); $form['revision_information']['log'] = array( '#type' => 'textarea', diff --git modules/system/system.admin.inc modules/system/system.admin.inc index 4c75c8e..8c34638 100644 --- modules/system/system.admin.inc +++ modules/system/system.admin.inc @@ -480,6 +480,9 @@ function system_theme_settings(&$form_state, $key = '') { '#title' => t('Logo image settings'), '#description' => t('If toggled on, the following logo will be displayed.'), '#attributes' => array('class' => array('theme-settings-bottom')), + '#dependencies' => array( + 'invisible' => array('input[name="toggle_logo"]' => array('checked' => FALSE)), + ), ); $form['logo']['default_logo'] = array( '#type' => 'checkbox', @@ -492,13 +495,20 @@ function system_theme_settings(&$form_state, $key = '') { '#type' => 'textfield', '#title' => t('Path to custom logo'), '#default_value' => $settings['logo_path'], - '#description' => t('The path to the file you would like to use as your logo file instead of the default logo.')); + '#description' => t('The path to the file you would like to use as your logo file instead of the default logo.'), + '#dependencies' => array( + 'invisible' => array('input[name="default_logo"]' => array('checked' => TRUE)), + ), + ); $form['logo']['logo_upload'] = array( '#type' => 'file', '#title' => t('Upload logo image'), '#maxlength' => 40, - '#description' => t("If you don't have direct file access to the server, use this field to upload your logo.") + '#description' => t("If you don't have direct file access to the server, use this field to upload your logo."), + '#dependencies' => array( + 'invisible' => array('input[name="default_logo"]' => array('checked' => TRUE)), + ), ); } @@ -507,6 +517,9 @@ function system_theme_settings(&$form_state, $key = '') { '#type' => 'fieldset', '#title' => t('Shortcut icon settings'), '#description' => t("Your shortcut icon, or 'favicon', is displayed in the address bar and bookmarks of most browsers."), + '#dependencies' => array( + 'invisible' => array('input[name="toggle_favicon"]' => array('checked' => FALSE)), + ), ); $form['favicon']['default_favicon'] = array( '#type' => 'checkbox', @@ -518,13 +531,19 @@ function system_theme_settings(&$form_state, $key = '') { '#type' => 'textfield', '#title' => t('Path to custom icon'), '#default_value' => $settings['favicon_path'], - '#description' => t('The path to the image file you would like to use as your custom shortcut icon.') + '#description' => t('The path to the image file you would like to use as your custom shortcut icon.'), + '#dependencies' => array( + 'invisible' => array('input[name="default_favicon"]' => array('checked' => TRUE)), + ), ); $form['favicon']['favicon_upload'] = array( '#type' => 'file', '#title' => t('Upload icon image'), - '#description' => t("If you don't have direct file access to the server, use this field to upload your shortcut icon.") + '#description' => t("If you don't have direct file access to the server, use this field to upload your shortcut icon."), + '#dependencies' => array( + 'invisible' => array('input[name="default_favicon"]' => array('checked' => TRUE)), + ), ); } @@ -1670,17 +1689,18 @@ function system_regional_settings() { '#default_value' => $configurable_timezones, ); - $js_hide = !$configurable_timezones ? ' class="js-hide"' : ''; $form['timezone']['configurable_timezones_wrapper'] = array( - '#prefix' => '
', - '#suffix' => '
', + '#type' => 'wrapper', + '#dependencies' => array( + 'invisible' => array('input[name="configurable_timezones"]' => array('checked' => FALSE)) + ), ); $form['timezone']['configurable_timezones_wrapper']['empty_timezone_message'] = array( '#type' => 'checkbox', '#title' => t('Remind users at login if their time zone is not set.'), '#default_value' => variable_get('empty_timezone_message', 0), - '#description' => t('Only applied if users may set their own time zone.') + '#description' => t('Only applied if users may set their own time zone.'), ); $form['timezone']['configurable_timezones_wrapper']['user_default_timezone'] = array( @@ -1692,7 +1712,7 @@ function system_regional_settings() { DRUPAL_USER_TIMEZONE_EMPTY => t('Empty time zone.'), DRUPAL_USER_TIMEZONE_SELECT => t('Users may set their own time zone at registration.'), ), - '#description' => t('Only applied if users may set their own time zone.') + '#description' => t('Only applied if users may set their own time zone.'), ); $form['date_formats'] = array( diff --git modules/system/system.js modules/system/system.js index d84c779..e62c89e 100644 --- modules/system/system.js +++ modules/system/system.js @@ -117,19 +117,6 @@ Drupal.behaviors.dateTime = { }; /** - * Show/hide settings for user configurable time zones depending on whether - * users are able to set their own time zones or not. - */ -Drupal.behaviors.userTimeZones = { - attach: function (context, settings) { - $('#empty-timezone-message-wrapper .description').hide(); - $('#edit-configurable-timezones', context).change(function () { - $('#empty-timezone-message-wrapper').toggle(); - }); - }, -}; - -/** * Show the powered by Drupal image preview */ Drupal.behaviors.poweredByPreview = { diff --git modules/system/system.module modules/system/system.module index c02a725..99e05fc 100644 --- modules/system/system.module +++ modules/system/system.module @@ -481,6 +481,11 @@ function system_elements() { '#process' => array('form_process_vertical_tabs'), ); + $type['wrapper'] = array( + '#theme_wrappers' => array('wrapper'), + '#process' => array('form_process_wrapper'), + ); + $type['token'] = array( '#input' => TRUE, '#theme' => array('hidden'), diff --git modules/user/user.admin.inc modules/user/user.admin.inc index 7b1d444..a8b6026 100644 --- modules/user/user.admin.inc +++ modules/user/user.admin.inc @@ -337,15 +337,11 @@ function user_admin_settings() { '#default_value' => $picture_support, ); drupal_add_js(drupal_get_path('module', 'user') . '/user.js'); - // If JS is enabled, and the checkbox defaults to off, hide all the settings - // on page load via CSS using the js-hide class so there's no flicker. - $css_class = 'user-admin-picture-settings'; - if (!$picture_support) { - $css_class .= ' js-hide'; - } $form['personalization']['pictures'] = array( - '#prefix' => '
', - '#suffix' => '
', + '#type' => 'wrapper', + '#dependencies' => array( + 'invisible' => array('input[name="user_pictures"]' => array('checked' => FALSE)), + ), ); $form['personalization']['pictures']['user_picture_path'] = array( '#type' => 'textfield', @@ -511,12 +507,18 @@ function user_admin_settings() { '#title' => t('Subject'), '#default_value' => _user_mail_text('status_activated_subject'), '#maxlength' => 180, + '#dependencies' => array( + 'invisible' => array('input[name="user_mail_status_activated_notify"]' => array('checked' => FALSE)), + ), ); $form['email_activated']['user_mail_status_activated_body'] = array( '#type' => 'textarea', '#title' => t('Body'), '#default_value' => _user_mail_text('status_activated_body'), '#rows' => 15, + '#dependencies' => array( + 'invisible' => array('input[name="user_mail_status_activated_notify"]' => array('checked' => FALSE)), + ), ); $form['email_blocked'] = array( @@ -537,12 +539,18 @@ function user_admin_settings() { '#title' => t('Subject'), '#default_value' => _user_mail_text('status_blocked_subject'), '#maxlength' => 180, + '#dependencies' => array( + 'invisible' => array('input[name="user_mail_status_blocked_notify"]' => array('checked' => FALSE)), + ), ); $form['email_blocked']['user_mail_status_blocked_body'] = array( '#type' => 'textarea', '#title' => t('Body'), '#default_value' => _user_mail_text('status_blocked_body'), '#rows' => 3, + '#dependencies' => array( + 'invisible' => array('input[name="user_mail_status_blocked_notify"]' => array('checked' => FALSE)), + ), ); $form['email_cancel_confirm'] = array( @@ -584,12 +592,18 @@ function user_admin_settings() { '#title' => t('Subject'), '#default_value' => _user_mail_text('status_canceled_subject'), '#maxlength' => 180, + '#dependencies' => array( + 'invisible' => array('input[name="user_mail_status_canceled_notify"]' => array('checked' => FALSE)), + ), ); $form['email_canceled']['user_mail_status_canceled_body'] = array( '#type' => 'textarea', '#title' => t('Body'), '#default_value' => _user_mail_text('status_canceled_body'), '#rows' => 3, + '#dependencies' => array( + 'invisible' => array('input[name="user_mail_status_canceled_notify"]' => array('checked' => FALSE)), + ), ); return system_settings_form($form, FALSE); diff --git modules/user/user.js modules/user/user.js index c492656..d4d2303 100644 --- modules/user/user.js +++ modules/user/user.js @@ -160,16 +160,4 @@ Drupal.evaluatePasswordStrength = function (password, translate) { return { strength: strength, message: msg }; }; -/** - * Show all of the picture-related form elements at admin/config/people/accounts - * depending on whether user pictures are enabled or not. - */ -Drupal.behaviors.userSettings = { - attach: function (context, settings) { - $('#edit-user-pictures', context).change(function () { - $('div.user-admin-picture-settings', context).toggle(); - }); - } -}; - })(jQuery);