1/** 2 * Front-end behavior for CC_GF_CompoundEntryTermField. 3 * 4 * Two dependent questions in one Gravity Forms field: 5 * 6 * 1. Student Type - a radio group (or a hidden input, when the field is 7 * locked to one type via the ccFixedStudentType setting). 8 * 2. A second question whose visible label, placeholder and option list are 9 * all derived from the answer to question 1. It stays hidden until a 10 * Student Type has been chosen. 11 * 12 * All date math runs against the visitor's local clock (new Date()) rather 13 * than server time, so a page served from cache still produces a correct, 14 * current option list. 15 * 16 * Cutoff dates, year-counts AND the label/placeholder strings all come from 17 * the per-field config baked into data-gf-compound-entry-term-config by the 18 * PHP field class. Nothing user-visible is hard-coded here - keeping the 19 * strings server-side means they stay translatable and live in one place. 20 */ 21( function ( $ ) { 22 'use strict'; 23 24 // First-Year: plain Fall terms, values "{year}FA". Starts at the 25 // current calendar year, unless today has already passed this year's 26 // cutoff (default 7/1) - in which case the list starts at next year 27 // instead. cfg: { yearsCount, cutoffMonth, cutoffDay } 28 function buildFirstYearOptions( today, cfg ) { 29 var y = today.getFullYear(); 30 var cutoff = new Date( y, cfg.cutoffMonth - 1, cfg.cutoffDay ); 31 var baseYear = today >= cutoff ? y + 1 : y; 32 33 var options = []; 34 for ( var i = 0; i < cfg.yearsCount; i++ ) { 35 var year = baseYear + i; 36 options.push( { label: String( year ), value: year + 'FA' } ); 37 } 38 return options; 39 } 40 41 // Transfer: a Spring + Fall pair for each year starting at the current 42 // calendar year. Each year's Spring/Fall is checked against its own 43 // cutoff independently - only the current year can realistically be 44 // affected, since future years' cutoff dates haven't happened yet. 45 // cfg: { yearsCount, fallCutoffMonth, fallCutoffDay, 46 // springCutoffMonth, springCutoffDay } 47 function buildTransferOptions( today, cfg ) { 48 var y = today.getFullYear(); 49 var terms = []; 50 51 for ( var i = 0; i < cfg.yearsCount; i++ ) { 52 var year = y + i; 53 54 var springCutoff = new Date( year, cfg.springCutoffMonth - 1, cfg.springCutoffDay ); 55 if ( today < springCutoff ) { 56 terms.push( { year: year, term: 'SP' } ); 57 } 58 59 var fallCutoff = new Date( year, cfg.fallCutoffMonth - 1, cfg.fallCutoffDay ); 60 if ( today < fallCutoff ) { 61 terms.push( { year: year, term: 'FA' } ); 62 } 63 } 64 65 // Chronological order: Spring before Fall within the same year. 66 terms.sort( function ( a, b ) { 67 var av = a.year + ( a.term === 'FA' ? 0.5 : 0 ); 68 var bv = b.year + ( b.term === 'FA' ? 0.5 : 0 ); 69 return av - bv; 70 } ); 71 72 return terms.map( function ( t ) { 73 return { 74 label: ( t.term === 'FA' ? 'Fall ' : 'Spring ' ) + t.year, 75 value: t.year + t.term 76 }; 77 } ); 78 } 79 80 /** 81 * Resolve the currently selected Student Type for a field instance. 82 * 83 * Handles both render modes: a radio group (visitor chooses) and a single 84 * hidden input (ccFixedStudentType locks the field to one type). 85 * 86 * Calling .val() directly on a radio set is unsafe - jQuery returns the 87 * FIRST radio's value whether or not anything is checked, which would 88 * make the field behave as though "first_year" were always selected. 89 * 90 * @param {jQuery} $wrap The .cc-compound-entry-term-field container. 91 * @return {string} 'first_year', 'transfer', or '' if nothing selected. 92 */ 93 function getStudentType( $wrap ) { 94 var $inputs = $wrap.find( '.cc-student-type-select' ); 95 var $radios = $inputs.filter( '[type="radio"]' ); 96 97 if ( $radios.length ) { 98 return $radios.filter( ':checked' ).val() || ''; 99 } 100 101 return $inputs.val() || ''; 102 } 103 104 /** 105 * Show or hide the second question. 106 * 107 * Uses the `hidden` attribute rather than a CSS class so the subtree is 108 * removed from the accessibility tree and the tab order, not merely 109 * painted out (WCAG 2.4.3 Focus Order). The select is disabled
109while 110 * hidden, which also keeps it out of the POST - so a required field 111 * correctly fails validation when no Student Type was chosen. 112 * 113 * Focus is deliberately NOT moved into the revealed control: the new 114 * question appears immediately after the radio the visitor just 115 * activated, so natural tab order reaches it, and stealing focus would 116 * be an unexpected change of context (WCAG 3.2.2 On Input). 117 * 118 * @param {jQuery} $wrapper The .cc-entry-term-wrap element. 119 * @param {jQuery} $select The .cc-entry-term-select element. 120 * @param {boolean} reveal Whether to show the question. 121 */ 122 function setEntryTermVisibility( $wrapper, $select, reveal ) { 123 $wrapper.prop( 'hidden', ! reveal ); 124 $select.prop( 'disabled', ! reveal ); 125 } 126 127 /** 128 * Rebuild the second question for the current Student Type. 129 * 130 * Sets the visible label text, the placeholder option and the option list 131 * from the per-field config, then reveals the question. Hides and clears 132 * it when no Student Type is selected. 133 * 134 * @param {jQuery} $wrap The .cc-compound-entry-term-field container. 135 * @param {boolean} isInitial True on first population (page load or 136 * re-render), false when the visitor changed 137 * Student Type themselves. 138 */ 139 function populateEntryTerm( $wrap, isInitial ) { 140 var config = $wrap.data( 'gfCompoundEntryTermConfig' ); 141 var $wrapper = $wrap.find( '.cc-entry-term-wrap' ); 142 var $label = $wrap.find( '.cc-entry-term-label' ); 143 var $entryTerm = $wrap.find( '.cc-entry-term-select' ); 144 var studentType = getStudentType( $wrap ); 145 var today = new Date(); 146 147 if ( ! config || ! $entryTerm.length ) { 148 return; 149 } 150 151 // The server-provided saved value (data-current-value) only makes 152 // sense to restore on the very first populate call - e.g. editing 153 // an existing entry, or redisplaying the form after a validation 154 // error. Once the visitor changes Student Type themselves, any 155 // prior Entry Term selection belongs to the old option set and 156 // won't exist in the new one - trying to reapply it just leaves 157 // the select blank instead of showing the placeholder. 158 var currentValue = isInitial ? $entryTerm.data( 'currentValue' ) : null; 159 160 $entryTerm.empty(); 161 162 if ( ! studentType ) { 163 // Nothing chosen yet: keep the question out of the document 164 // flow entirely rather than showing a disabled placeholder. 165 setEntryTermVisibility( $wrapper, $entryTerm, false ); 166 return; 167 } 168 169 var strings = studentType === 'first_year' ? config.firstYear : config.transfer; 170 var options = studentType === 'first_year' 171 ? buildFirstYearOptions( today, config.firstYear ) 172 : buildTransferOptions( today, config.transfer ); 173 174 // Swap the VISIBLE label. The select carries no aria-label - an 175 // aria-label would override this text for screen reader users and 176 // they would hear something different from what is on screen. 177 // .text() (not .html()) so the string is treated as text, never markup. 178 $label.contents().filter( function () { 179 return this.nodeType === 3; // text nodes only - preserve the 180 // required asterisk <span> sibling. 181 } ).remove(); 182 $label.prepend( document.createTextNode( strings.label ) ); 183 184 $entryTerm.append( $( '<option>', { value: '', text: strings.placeholder } ) ); 185 $.each( options, function ( i, opt ) { 186 $entryTerm.append( $( '<option>', { value: opt.value, text: opt.label } ) ); 187 } ); 188 189 if ( currentValue ) { 190 $entryTerm.val( currentValue ); 191 // If the saved value isn't in the rebuilt list (e.g. it rolled 192 // off since the entry was saved), fall back to the placeholder 193 // instead of leaving the select with nothing visibly selected. 194 if ( $entryTerm.val() === null ) { 195 $entryTerm.val( '' ); 196 } 197 } 198 199 setEntryTermVisibility( $wrapper, $entryTerm, true ); 200 } 201 202 /** 203 * Populate every instance of the field on the page. 204 * 205 * Runs as the initial pass, so any Student Type already selected 206 * server-side (a locked field, or a value carried through a failed 207 * validation) is honoured and its saved Entry Term restored. 208 */ 209 function initAll() { 210 $( '.cc-compound-entry-term-field' ).each( function () { 211 populateEntryTerm( $( this ), true ); 212 } ); 213 } 214 215 // Delegated so it survives Gravity Forms re-rendering the markup. Works 216 // for radios as well as selects - `change` fires when a radio is 217 // activated by click, tap, arrow key or spacebar. 218 $( document ).on( 'change', '.cc-student-type-select', function () { 219 populateEntryTerm( $( this ).closest( '.cc-compound-entry-term-field' ), false ); 220 } ); 221 222 /** 223 * Teach Gravity Forms how to read this field's value for conditional logic. 224 * 225 * GF's client-side rule evaluation looks for a single element carrying the 226 * canonical input id. This field renders Student Type as a radio group 227 * (ids suffixed with the choice value), so that lookup finds nothing and 228 * every rule silently evaluates against an empty value - dependent fields 229 * then never show. gform_is_value_match is GF's documented extension point 230 * for exactly this case: a custom field type that stores its value in a 231 * non-standard way. 232 * 233 * Note rule.fieldId is a sub-input id such as "43.1", which cannot be 234 * interpolated into a selector directly - "#field_1_43.1" parses as id 235 * field_1_43 with class "1". The integer part is extracted for the 236 * container lookup and the decimal part identifies which input is targeted. 237 * 238 * Returns isMatch untouched for every rule that is not ours, so this cannot 239 * affect conditional logic elsewhere on the form. 240 * 241 * @param {boolean} isMatch GF's own comparison result. 242 * @param {number} formId Form being evaluated. 243 * @param {Object} rule { fieldId, operator, value }. 244 * @return {boolean} 245 */ 246 function matchCompoundEntryTermRule( isMatch, formId, rule ) { 247 var fieldId = parseInt( rule.fieldId, 10 ); 248 249 if ( isNaN( fieldId ) ) { 250 return isMatch; 251 } 252 253 var $wrap = jQuery( '#field_' + formId + '_' + fieldId ) 254 .find( '.cc-compound-entry-term-field' ); 255 256 if ( ! $wrap.length ) { 257 return isMatch; 258 } 259 260 var inputIndex = String( rule.fieldId ).split( '.' )[ 1 ]; 261 var value; 262 263 if ( '1' === inputIndex ) { 264 value = getStudentType( $wrap ); 265 } else if ( '2' === inputIndex ) { 266 value = $wrap.find( '.cc-entry-term-select' ).val() || ''; 267 } else { 268 // A rule against the parent field id has no single value to compare. 269 return isMatch; 270 } 271 272 return gf_matches_operation( value, rule.value, rule.operator ); 273 } 274 275 /** 276 * Register the filter once the gform object exists. 277 *
278 * This script declares no dependency on GF's own bundle, so gform may not be 279 * defined at parse time. Registration is attempted immediately and again on 280 * the later hooks, guarded by a flag so it only ever takes effect once. 281 */ 282 var clFilterRegistered = false; 283 284 function registerConditionalLogicFilter() { 285 if ( clFilterRegistered || typeof gform === 'undefined' || ! gform.addFilter ) { 286 return; 287 } 288 289 gform.addFilter( 'gform_is_value_match', matchCompoundEntryTermRule ); 290 clFilterRegistered = true; 291 } 292 293 registerConditionalLogicFilter(); 294 $( document ).ready( registerConditionalLogicFilter ); 295 $( document ).ready( initAll ); 296 297 // Gravity Forms re-renders the field markup after AJAX page changes 298 // and after conditional-logic-driven redraws - re-init each time. 299 $( document ).on( 'gform_post_render', initAll ); 300 $( document ).on( 'gform_post_render', registerConditionalLogicFilter ); 301 302} )( jQuery );
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.