2015-08-17 17:00:26 -07:00
/ * *
* @ file
* Drupal ' s states library .
* /
( function ( $ ) {
2015-10-21 21:44:50 -07:00
'use strict' ;
2015-08-17 17:00:26 -07:00
/ * *
* The base States namespace .
*
* Having the local states variable allows us to use the States namespace
* without having to always declare "Drupal.states" .
*
* @ namespace Drupal . states
* /
var states = Drupal . states = {
/ * *
* An array of functions that should be postponed .
* /
postponed : [ ]
} ;
/ * *
* Attaches the states .
*
* @ type { Drupal ~ behavior }
* /
Drupal . behaviors . states = {
attach : function ( context , settings ) {
var $states = $ ( context ) . find ( '[data-drupal-states]' ) ;
var config ;
var state ;
var il = $states . length ;
for ( var i = 0 ; i < il ; i ++ ) {
config = JSON . parse ( $states [ i ] . getAttribute ( 'data-drupal-states' ) ) ;
for ( state in config ) {
if ( config . hasOwnProperty ( state ) ) {
new states . Dependent ( {
element : $ ( $states [ i ] ) ,
state : states . State . sanitize ( state ) ,
constraints : config [ state ]
} ) ;
}
}
}
// Execute all postponed functions now.
while ( states . postponed . length ) {
( states . postponed . shift ( ) ) ( ) ;
}
}
} ;
/ * *
* Object representing an element that depends on other elements .
*
* @ constructor Drupal . states . Dependent
*
* @ param { object } args
* Object with the following keys ( all of which are required )
* @ param { jQuery } args . element
* A jQuery object of the dependent element
* @ param { Drupal . states . State } args . state
* A State object describing the state that is dependent
* @ param { object } args . constraints
* An object with dependency specifications . Lists all elements that this
* element depends on . It can be nested and can contain
* arbitrary AND and OR clauses .
* /
states . Dependent = function ( args ) {
$ . extend ( this , { values : { } , oldValue : null } , args ) ;
this . dependees = this . getDependees ( ) ;
for ( var selector in this . dependees ) {
if ( this . dependees . hasOwnProperty ( selector ) ) {
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 .
*
* @ name Drupal . states . Dependent . comparisons
*
* @ prop { function } RegExp
* @ prop { function } Function
* @ prop { function } Number
* /
states . Dependent . comparisons = {
2015-09-04 13:20:09 -07:00
RegExp : function ( reference , value ) {
2015-08-17 17:00:26 -07:00
return reference . test ( value ) ;
} ,
2015-09-04 13:20:09 -07:00
Function : function ( reference , value ) {
2015-08-17 17:00:26 -07:00
// The "reference" variable is a comparison function.
return reference ( value ) ;
} ,
2015-09-04 13:20:09 -07:00
Number : function ( reference , value ) {
2015-08-17 17:00:26 -07:00
// If "reference" is a number and "value" is a string, then cast
// reference as a string before applying the strict comparison in
// compare().
// Otherwise numeric keys in the form's #states array fail to match
// string values returned from jQuery's val().
return ( typeof value === 'string' ) ? compare ( reference . toString ( ) , value ) : compare ( reference , value ) ;
}
} ;
states . Dependent . prototype = {
/ * *
* Initializes one of the elements this dependent depends on .
*
* @ memberof Drupal . states . Dependent #
*
* @ param { string } selector
* The CSS selector describing the dependee .
* @ param { object } dependeeStates
* The list of states that have to be monitored for tracking the
* dependee ' s compliance status .
* /
initializeDependee : function ( selector , dependeeStates ) {
var state ;
var self = this ;
function stateEventHandler ( e ) {
self . update ( e . data . selector , e . data . state , e . value ) ;
}
// Cache for the states of this dependee.
this . values [ selector ] = { } ;
for ( var i in dependeeStates ) {
if ( dependeeStates . hasOwnProperty ( i ) ) {
state = dependeeStates [ i ] ;
// Make sure we're not initializing this selector/state combination
// twice.
if ( $ . inArray ( state , dependeeStates ) === - 1 ) {
continue ;
}
state = states . State . sanitize ( state ) ;
// Initialize the value of this state.
this . values [ selector ] [ state . name ] = null ;
// Monitor state changes of the specified state for this dependee.
$ ( selector ) . on ( 'state:' + state , { selector : selector , state : state } , stateEventHandler ) ;
// Make sure the event we just bound ourselves to is actually fired.
new states . Trigger ( { selector : selector , state : state } ) ;
}
}
} ,
/ * *
* Compares a value with a reference value .
*
* @ memberof Drupal . states . Dependent #
*
* @ param { object } reference
* The value used for reference .
* @ param { string } selector
* CSS selector describing the dependee .
* @ param { Drupal . states . State } state
* A State object describing the dependee ' s updated state .
*
* @ return { bool }
* true or false .
* /
compare : function ( reference , selector , state ) {
var value = this . values [ selector ] [ state . name ] ;
if ( reference . constructor . name in states . Dependent . comparisons ) {
// Use a custom compare function for certain reference value types.
return states . Dependent . comparisons [ reference . constructor . name ] ( reference , value ) ;
}
else {
// Do a plain comparison otherwise.
return compare ( reference , value ) ;
}
} ,
/ * *
* Update the value of a dependee ' s state .
*
* @ memberof Drupal . states . Dependent #
*
* @ param { string } selector
* CSS selector describing the dependee .
* @ param { Drupal . states . state } state
* A State object describing the dependee ' s updated state .
* @ param { string } 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 . name ] ) {
this . values [ selector ] [ state . name ] = value ;
this . reevaluate ( ) ;
}
} ,
/ * *
* Triggers change events in case a state changed .
*
* @ memberof Drupal . states . Dependent #
* /
reevaluate : function ( ) {
// Check whether any constraint for this dependent state is satisfied.
var value = this . verifyConstraints ( this . constraints ) ;
// 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 } ) ;
}
} ,
/ * *
* Evaluates child constraints to determine if a constraint is satisfied .
*
* @ memberof Drupal . states . Dependent #
*
* @ param { object | Array } constraints
* A constraint object or an array of constraints .
* @ param { string } selector
* The selector for these constraints . If undefined , there isn ' t yet a
* selector that these constraints apply to . In that case , the keys of the
* object are interpreted as the selector if encountered .
*
* @ return { bool }
* true or false , depending on whether these constraints are satisfied .
* /
verifyConstraints : function ( constraints , selector ) {
var result ;
if ( $ . isArray ( constraints ) ) {
// This constraint is an array (OR or XOR).
var hasXor = $ . inArray ( 'xor' , constraints ) === - 1 ;
var len = constraints . length ;
for ( var i = 0 ; i < len ; i ++ ) {
if ( constraints [ i ] !== 'xor' ) {
var constraint = this . checkConstraints ( constraints [ i ] , selector , i ) ;
// Return if this is OR and we have a satisfied constraint or if
// this is XOR and we have a second satisfied constraint.
if ( constraint && ( hasXor || result ) ) {
return hasXor ;
}
result = result || constraint ;
}
}
}
// Make sure we don't try to iterate over things other than objects. This
// shouldn't normally occur, but in case the condition definition is
// bogus, we don't want to end up with an infinite loop.
else if ( $ . isPlainObject ( constraints ) ) {
// This constraint is an object (AND).
for ( var n in constraints ) {
if ( constraints . hasOwnProperty ( n ) ) {
result = ternary ( result , this . checkConstraints ( constraints [ n ] , selector , n ) ) ;
// False and anything else will evaluate to false, so return when
// any false condition is found.
if ( result === false ) { return false ; }
}
}
}
return result ;
} ,
/ * *
* Checks whether the value matches the requirements for this constraint .
*
* @ memberof Drupal . states . Dependent #
*
* @ param { string | Array | object } value
* Either the value of a state or an array / object of constraints . In the
* latter case , resolving the constraint continues .
* @ param { string } [ selector ]
* The selector for this constraint . If undefined , there isn ' t yet a
* selector that this constraint applies to . In that case , the state key
* is propagates to a selector and resolving continues .
* @ param { Drupal . states . State } [ state ]
* The state to check for this constraint . If undefined , resolving
* continues . If both selector and state aren ' t undefined and valid
* non - numeric strings , a lookup for the actual value of that selector ' s
* state is performed . This parameter is not a State object but a pristine
* state string .
*
* @ return { bool }
* true or false , depending on whether this constraint is satisfied .
* /
checkConstraints : function ( value , selector , state ) {
// Normalize the last parameter. If it's non-numeric, we treat it either
// as a selector (in case there isn't one yet) or as a trigger/state.
if ( typeof state !== 'string' || ( /[0-9]/ ) . test ( state [ 0 ] ) ) {
state = null ;
}
else if ( typeof selector === 'undefined' ) {
// Propagate the state to the selector when there isn't one yet.
selector = state ;
state = null ;
}
if ( state !== null ) {
// Constraints is the actual constraints of an element to check for.
state = states . State . sanitize ( state ) ;
return invert ( this . compare ( value , selector , state ) , state . invert ) ;
}
else {
// Resolve this constraint as an AND/OR operator.
return this . verifyConstraints ( value , selector ) ;
}
} ,
/ * *
* Gathers information about all required triggers .
*
* @ memberof Drupal . states . Dependent #
*
* @ return { object }
* /
getDependees : function ( ) {
var cache = { } ;
// Swivel the lookup function so that we can record all available
// selector- state combinations for initialization.
var _compare = this . compare ;
this . compare = function ( reference , selector , state ) {
( cache [ selector ] || ( cache [ selector ] = [ ] ) ) . push ( state . name ) ;
// Return nothing (=== undefined) so that the constraint loops are not
// broken.
} ;
// This call doesn't actually verify anything but uses the resolving
// mechanism to go through the constraints array, trying to look up each
// value. Since we swivelled the compare function, this comparison returns
// undefined and lookup continues until the very end. Instead of lookup up
// the value, we record that combination of selector and state so that we
// can initialize all triggers.
this . verifyConstraints ( this . constraints ) ;
// Restore the original function.
this . compare = _compare ;
return cache ;
}
} ;
/ * *
* @ constructor Drupal . states . Trigger
*
* @ param { object } args
* /
states . Trigger = function ( args ) {
$ . extend ( this , args ) ;
if ( this . state in states . 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 ( ) ;
}
}
} ;
states . Trigger . prototype = {
/ * *
* @ memberof Drupal . states . Trigger #
* /
initialize : function ( ) {
var trigger = states . Trigger . states [ this . state ] ;
if ( typeof trigger === 'function' ) {
// We have a custom trigger initialization function.
trigger . call ( window , this . element ) ;
}
else {
for ( var event in trigger ) {
if ( trigger . hasOwnProperty ( event ) ) {
this . defaultTrigger ( event , trigger [ event ] ) ;
}
}
}
// Mark this trigger as initialized for this element.
this . element . data ( 'trigger:' + this . state , true ) ;
} ,
/ * *
* @ memberof Drupal . states . Trigger #
*
* @ param { jQuery . Event } event
* @ param { function } valueFn
* /
defaultTrigger : function ( event , valueFn ) {
var oldValue = valueFn . call ( this . element ) ;
// Attach the event callback.
this . element . on ( event , $ . proxy ( function ( e ) {
var value = valueFn . call ( this . element , e ) ;
// Only trigger the event if the value has actually changed.
if ( oldValue !== value ) {
this . element . trigger ( { type : 'state:' + this . state , value : value , oldValue : oldValue } ) ;
oldValue = value ;
}
} , this ) ) ;
states . postponed . push ( $ . proxy ( function ( ) {
// Trigger the event once for initialization purposes.
this . element . trigger ( { type : 'state:' + this . state , value : oldValue , oldValue : null } ) ;
} , this ) ) ;
}
} ;
/ * *
* 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
* dependent element can be updated .
*
* @ name Drupal . states . Trigger . states
*
* @ prop empty
* @ prop checked
* @ prop value
* @ prop collapsed
* /
states . Trigger . states = {
// 'empty' describes the state to be monitored.
empty : {
// 'keyup' is the (native DOM) event that we watch for.
2015-09-04 13:20:09 -07:00
keyup : function ( ) {
// The function associated with that trigger returns the new value for
// the state.
2015-08-17 17:00:26 -07:00
return this . val ( ) === '' ;
}
} ,
checked : {
2015-09-04 13:20:09 -07:00
change : function ( ) {
2015-08-17 17:00:26 -07:00
// prop() and attr() only takes the first element into account. To
// support selectors matching multiple checkboxes, iterate over all and
// return whether any is checked.
var checked = false ;
this . each ( function ( ) {
// Use prop() here as we want a boolean of the checkbox state.
// @see http://api.jquery.com/prop/
checked = $ ( this ) . prop ( 'checked' ) ;
// Break the each() loop if this is checked.
return ! checked ;
} ) ;
return checked ;
}
} ,
// For radio buttons, only return the value if the radio button is selected.
value : {
2015-09-04 13:20:09 -07:00
keyup : function ( ) {
2015-08-17 17:00:26 -07:00
// Radio buttons share the same :input[name="key"] selector.
if ( this . length > 1 ) {
// Initial checked value of radios is undefined, so we return false.
return this . filter ( ':checked' ) . val ( ) || false ;
}
return this . val ( ) ;
} ,
2015-09-04 13:20:09 -07:00
change : function ( ) {
2015-08-17 17:00:26 -07:00
// Radio buttons share the same :input[name="key"] selector.
if ( this . length > 1 ) {
// Initial checked value of radios is undefined, so we return false.
return this . filter ( ':checked' ) . val ( ) || false ;
}
return this . val ( ) ;
}
} ,
collapsed : {
2015-09-04 13:20:09 -07:00
collapsed : function ( e ) {
2015-08-17 17:00:26 -07:00
return ( typeof e !== 'undefined' && 'value' in e ) ? e . value : ! this . is ( '[open]' ) ;
}
}
} ;
/ * *
* A state object is used for describing the state and performing aliasing .
*
* @ constructor Drupal . states . State
*
* @ param { string } state
* /
states . State = function ( state ) {
/ * *
* Original unresolved name .
* /
this . pristine = this . name = state ;
// Normalize the state name.
var process = true ;
do {
// 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 states . State . aliases ) {
this . name = states . State . aliases [ this . name ] ;
}
else {
process = false ;
}
} while ( process ) ;
} ;
/ * *
* Creates a new State object by sanitizing the passed value .
*
* @ name Drupal . states . State . sanitize
*
* @ param { string | Drupal . states . State } state
*
* @ return { Drupal . states . state }
* /
states . State . sanitize = function ( state ) {
if ( state instanceof states . State ) {
return state ;
}
else {
return new states . State ( state ) ;
}
} ;
/ * *
* This list of aliases is used to normalize states and associates negated
* names with their respective inverse state .
*
* @ name Drupal . states . State . aliases
* /
states . State . aliases = {
2015-09-04 13:20:09 -07:00
enabled : '!disabled' ,
invisible : '!visible' ,
invalid : '!valid' ,
untouched : '!touched' ,
optional : '!required' ,
filled : '!empty' ,
unchecked : '!checked' ,
irrelevant : '!relevant' ,
expanded : '!collapsed' ,
open : '!collapsed' ,
closed : 'collapsed' ,
readwrite : '!readonly'
2015-08-17 17:00:26 -07:00
} ;
states . State . prototype = {
/ * *
* @ memberof Drupal . states . State #
* /
invert : false ,
/ * *
* Ensures that just using the state object returns the name .
*
* @ memberof Drupal . states . State #
*
* @ return { string }
* /
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 ) . on ( '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 )
. prop ( 'disabled' , e . value )
2015-10-08 11:40:12 -07:00
. closest ( '.js-form-item, .js-form-submit, .js-form-wrapper' ) . toggleClass ( 'form-disabled' , e . value )
2015-08-17 17:00:26 -07:00
. find ( 'select, input, textarea' ) . prop ( 'disabled' , e . value ) ;
// Note: WebKit nightlies don't reflect that change correctly.
// See https://bugs.webkit.org/show_bug.cgi?id=23789
}
} ) ;
$ ( document ) . on ( 'state:required' , function ( e ) {
if ( e . trigger ) {
if ( e . value ) {
2015-10-08 11:40:12 -07:00
var label = 'label' + ( e . target . id ? '[for=' + e . target . id + ']' : '' ) ;
var $label = $ ( e . target ) . attr ( { 'required' : 'required' , 'aria-required' : 'aria-required' } ) . closest ( '.js-form-item, .js-form-wrapper' ) . find ( label ) ;
2015-08-17 17:00:26 -07:00
// Avoids duplicate required markers on initialization.
2015-09-04 13:20:09 -07:00
if ( ! $label . hasClass ( 'js-form-required' ) . length ) {
$label . addClass ( 'js-form-required form-required' ) ;
2015-08-17 17:00:26 -07:00
}
}
else {
2015-10-08 11:40:12 -07:00
$ ( e . target ) . removeAttr ( 'required aria-required' ) . closest ( '.js-form-item, .js-form-wrapper' ) . find ( 'label.js-form-required' ) . removeClass ( 'js-form-required form-required' ) ;
2015-08-17 17:00:26 -07:00
}
}
} ) ;
$ ( document ) . on ( 'state:visible' , function ( e ) {
if ( e . trigger ) {
2015-10-08 11:40:12 -07:00
$ ( e . target ) . closest ( '.js-form-item, .js-form-submit, .js-form-wrapper' ) . toggle ( e . value ) ;
2015-08-17 17:00:26 -07:00
}
} ) ;
$ ( document ) . on ( 'state:checked' , function ( e ) {
if ( e . trigger ) {
$ ( e . target ) . prop ( 'checked' , e . value ) ;
}
} ) ;
$ ( document ) . on ( 'state:collapsed' , function ( e ) {
if ( e . trigger ) {
if ( $ ( e . target ) . is ( '[open]' ) === e . value ) {
$ ( e . target ) . find ( '> summary a' ) . trigger ( 'click' ) ;
}
}
} ) ;
/ * *
* These are helper functions implementing addition "operators" and don ' t
* implement any logic that is particular to states .
* /
/ * *
* Bitwise AND with a third undefined state .
*
* @ function Drupal . states ~ ternary
*
* @ param { * } a
* @ param { * } b
*
* @ return { bool }
* /
function ternary ( a , b ) {
if ( typeof a === 'undefined' ) {
return b ;
}
else if ( typeof b === 'undefined' ) {
return a ;
}
else {
return a && b ;
}
}
/ * *
* Inverts a ( if it ' s not undefined ) when invertState is true .
*
* @ function Drupal . states ~ invert
*
* @ param { * } a
* @ param { bool } invertState
*
* @ return { bool }
* /
function invert ( a , invertState ) {
return ( invertState && typeof a !== 'undefined' ) ? ! a : a ;
}
/ * *
* Compares two values while ignoring undefined values .
*
* @ function Drupal . states ~ compare
*
* @ param { * } a
* @ param { * } b
*
* @ return { bool }
* /
function compare ( a , b ) {
if ( a === b ) {
return typeof a === 'undefined' ? a : true ;
}
else {
return typeof a === 'undefined' || typeof b === 'undefined' ;
}
}
} ) ( jQuery ) ;