5a920362 |
1 | /*jslint white:false, onevar:true, undef:true, nomen:true, eqeqeq:true, plusplus:true, bitwise:true, regexp:true, newcap:true, immed:true, strict:false, browser:true */ |
2 | /* |
3 | // jQuery multiSelect |
4 | // |
5 | // Version 1.3 |
6 | // |
7 | // Cory S.N. LaViska |
8 | // A Beautiful Site (http://abeautifulsite.net/) |
9 | // Visit http://abeautifulsite.net/notebook/62 for more information |
10 | // |
11 | // (Amended by Andy Richmond, Letters & Science Deans' Office, University of California, Davis) |
12 | // |
13 | // (Amended and trimmed for the Plone open source CMS by Matt Barkau, WebLion Group, PSU.edu) |
14 | // |
15 | // Usage: $('#control_id').multiSelect( arguments ) |
16 | // |
17 | // Arguments: |
18 | // noneSelected - text to display when there are no selected items in the list |
19 | // oneOrMoreSelected - text to display when there are one or more selected items in the list |
20 | // (note: you can use % as a placeholder for the number of items selected). |
21 | // Use * to show a comma separated list of all selected; default = '% selected' |
22 | // |
23 | // Dependencies: jQuery 1.2.6 or higher (http://jquery.com/) |
24 | // |
25 | // Change Log: |
26 | // |
27 | // 1.0.1 - Updated to work with jQuery 1.2.6+ (no longer requires the dimensions plugin) |
28 | // - Changed $(this).offset() to $(this).position(), per James' and Jono's suggestions |
29 | // |
30 | // 1.0.2 - Fixed issue where dropdown doesn't scroll up/down with keyboard shortcuts |
31 | // - Changed '$' in setTimeout to use 'jQuery' to support jQuery.noConflict |
32 | // - Renamed from jqueryMultiSelect.* to jquery.multiSelect.* per the standard recommended at |
33 | // http://docs.jquery.com/Plugins/Authoring (does not affect API methods) |
34 | // |
35 | // 1.1.0 - Added the ability to update the options dynamically via javascript: optionsBoxUpdate(JSON) |
36 | // - Added a title that displays the whole comma delimited list when using oneOrMoreSelected = * |
37 | // - Moved some of the functions to be closured to make them private |
38 | // - Changed the way the keyboard navigation worked to more closely match how a standard dropdown works |
39 | // - ** by Andy Richmond ** |
40 | // |
41 | // 1.2.1 - Fixed bug where input text overlapped dropdown arrow in IE (i.e. when using oneOrMoreSelected = *) |
42 | // - ** by Andy Richmond ** |
43 | // |
44 | // 1.2.2 - 06 January 2010 (per http://abeautifulsite.net/blog/2008/04/jquery-multiselect ) |
45 | // - Fixed bug where the keypress stopped showing the dropdown because in jQuery 1.3.2 they changed the way ':visible' works |
46 | // - Fixed some other bugs in the way the keyboard interface worked |
47 | // - Changed the main textbox to an <a> tag (with 'display: inline-block') to prevent the display text from being selected/highlighted |
48 | // - Added the ability to jump to an option by typing the first character of that option (simular to a normal drop down) |
49 | // - ** by Andy Richmond ** |
50 | // - Added [] to make each control submit an HTML array so $.serialize() works properly |
51 | // |
52 | // 1.3 - 2010 October-November |
53 | // - Adapted for Plone open source CMS tag selector |
54 | // - Fixed bug related to mouse hover when using arrow keys |
55 | // - Improved many areas of keyboard support & accessibility |
56 | // - see https://dev.plone.org/archetypes/log/Products.Archetypes/branches/plip11017-tag-selector-rmattb/Products/Archetypes/skins/archetypes/widgets/js/keywordmultiselect.js |
57 | // - ** by Matt Barkau ** |
58 | // |
59 | // Licensing & Terms of Use |
60 | // |
61 | // This plugin is dual-licensed under the GNU General Public License and the MIT License and |
62 | // is copyright 2008 A Beautiful Site, LLC. |
63 | // |
64 | */ |
65 | |
66 | // Removed a test for jQuery, since we know it is available. |
67 | (function($) { |
68 | |
69 | // render the html for a single option |
70 | function renderOption(option, i, selectName) { |
71 | // dl, dt, & dd semantically associates selector name with values |
72 | // label makes the text clickable, like a multiple-select |
73 | var html = '<dd><label for="tag' + i + '"><input type="checkbox" name="' + selectName + '" value="' + option.value + '" id="tag' + i + '"'; |
74 | if( option.selected ) { |
75 | html += ' checked="checked"'; |
76 | } |
77 | html += ' />' + option.text + '</label></dd>'; |
78 | return html; |
79 | } |
80 | |
81 | // render the html for the options/optgroups |
82 | function renderOptions(options, selectName) { |
83 | var html = ""; |
84 | for(var i = 0; i < options.length; i++) { |
85 | html += renderOption(options[i], i, selectName); |
86 | } |
87 | return html; |
88 | } |
89 | |
90 | |
91 | // Plans to later modularize input and output handling, |
92 | // for better testability, modularity, and accessibility. |
93 | // // Handle mouse move input |
94 | // // Handle mouse click input |
95 | // // Handle key press input |
96 | // // Detect navigation with mouse or non-tab keys |
97 | // // Detect navigation with tab key |
98 | // // Detect selection with mouse |
99 | // // Detect selection with keyboard |
100 | // // Handle navigation of options |
101 | // // Handle selection of options |
102 | |
103 | |
104 | // Building the actual options |
105 | function buildOptions(options) { |
106 | var optionsBox = $(this); |
107 | var multiSelectA = optionsBox.next('.multiSelectA'); |
108 | |
109 | // Help text here is only relevant when there are many tags, |
110 | // so putting that in documentation, rather than here. |
111 | // "Hover and type the first letter to skip through tags." |
112 | $("#existingTagsHelp").text(''); |
113 | |
114 | // generate the html for the new options |
115 | html = renderOptions(options, optionsBox.attr('name')); |
116 | optionsBox.html(html); |
117 | |
118 | // Format selected options |
119 | optionsBox.each( function() { |
120 | $(this).find('INPUT:checked').parent('LABEL').addClass('checked'); |
121 | }); |
122 | |
123 | // Initialize selected options list |
124 | updateSelected.call(optionsBox); |
125 | var allOptions = optionsBox.find('LABEL'); |
126 | |
127 | // --- Navigation with Mouse --- |
128 | // Handle mouse hover of option, both |
129 | // entering an option, *and* |
130 | // mouse moving within an option. |
131 | optionsBox.find('LABEL').mousemove( function(e) { |
132 | // At this point, the browser is saying that the mouse moved. |
133 | // Workaround Safari's errant reporting of mousemove |
134 | // when the mouse hasn't moved, but background has. |
135 | // Initialize position variables. |
136 | if(multiSelectA.oldPositionX === null || multiSelectA.oldPositionY === null) { |
137 | multiSelectA.oldPositionX = e.pageX; |
138 | multiSelectA.oldPositionY = e.pageY; |
139 | } |
140 | if( multiSelectA.oldPositionX != e.pageX || multiSelectA.oldPositionY != e.pageY ) { |
141 | // At this point, the mouse actually did move. |
142 | // Highlight navigated option |
143 | $(this).parent().parent().find('LABEL').removeClass('hover'); // remove all highlights |
144 | $(this).addClass('hover'); // add the new highlight |
145 | lastNavTabKeyCheckbox = null; |
146 | multiSelectA.oldPositionX = e.pageX; |
147 | multiSelectA.oldPositionY = e.pageY; |
148 | multiSelectA.focus(); |
149 | adjustViewPort(optionsBox); |
150 | } |
151 | }); |
152 | |
153 | // --- Selection with Mouse --- |
154 | // Handle mouse click of checkbox |
155 | optionsBox.find('INPUT:checkbox').click( function() { |
156 | // set the label checked class |
157 | $(this).parent('LABEL').toggleClass('checked', $(this).attr('checked')); |
158 | |
159 | updateSelected.call(optionsBox); |
160 | // Highlight selected option |
161 | // placeholder |
162 | // Refocus |
163 | multiSelectA.focus(); |
164 | // If this checkbox was navigated to with the tab key before being checked, |
165 | // then put focus back on it. |
166 | if(typeof(lastNavTabKeyCheckbox) !== "undefined" && lastNavTabKeyCheckbox !== null) { |
167 | lastNavTabKeyCheckbox.focus(); |
168 | lastNavTabKeyCheckbox = null; |
169 | } |
170 | }); |
171 | |
172 | // --- Navigation with Tab Key --- |
173 | // Track mouse click of option |
174 | optionsBox.find('LABEL').mousedown(function() { |
175 | // Track mouse clicks, |
176 | // so that tab key navigation focus on checkboxes can be maintained separately. |
177 | lastNavClickTag = this; |
178 | }); |
179 | // Handle tab-key focus of checkbox |
180 | optionsBox.find('INPUT').focus(function() { |
181 | if(typeof(lastNavClickTag) == "undefined" || lastNavClickTag === null) { |
182 | // This only happens with tab key navgation. |
183 | // Must keep track of this, because |
184 | // mouse-driven nav always keeps *focus* on multiSelectA, |
185 | // while the active optionsBox get *hover*. |
186 | // Tab navigation is different - it's active option checkbox gets *focus*, |
187 | // rather than *hover*, since keyboard navigation never hovers. |
188 | // If the checkbox is tabbed to & checked , save it so that focus can be put back on it. |
189 | // Without this, both moused & tabbed checks return focus to multiSelectA, |
190 | // causing tabbed checkboxes to lose focus. |
191 | lastNavTabKeyCheckbox = $(this); |
192 | // Highlight navigated option |
193 | lastNavTabKeyCheckbox.parent().parent().parent().find('LABEL').removeClass('hover'); |
194 | lastNavTabKeyCheckbox.parent('LABEL').addClass('hover'); |
195 | } |
196 | lastNavClickTag = null; |
197 | }); |
198 | |
199 | // Handle keyboard press |
200 | multiSelectA.keydown( function(e) { |
201 | |
202 | var optionsBox = $(this).prev('.optionsBox'); |
203 | |
204 | // --- Navigation with Arrow or Page Keys --- |
205 | // Down || Up |
206 | if( e.keyCode == 40 || e.keyCode == 38) { |
207 | var oldHoverIndex = allOptions.index(allOptions.filter('.hover')); |
208 | var newHoverIndex = -1; |
209 | |
210 | // if there is no current highlighted item then highlight the first item |
211 | if(oldHoverIndex < 0) { |
212 | // Default to first item |
213 | optionsBox.find('LABEL:first').addClass('hover'); |
214 | } |
215 | // else if we are moving down and there is a next item then move |
216 | else if(e.keyCode == 40 && oldHoverIndex < allOptions.length - 1) { |
217 | newHoverIndex = oldHoverIndex + 1; |
218 | } |
219 | // else if we are moving up and there is a prev item then move |
220 | else if(e.keyCode == 38 && oldHoverIndex > 0) { |
221 | newHoverIndex = oldHoverIndex - 1; |
222 | } |
223 | |
224 | if(newHoverIndex >= 0) { |
225 | // Highlight navigated option |
226 | $(allOptions).removeClass('hover'); // remove old highlights |
227 | $(allOptions.get(newHoverIndex)).addClass('hover'); // add the new highlight |
228 | lastNavTabKeyCheckbox = null; |
229 | |
230 | // Adjust the viewport if necessary |
231 | adjustViewPort(optionsBox); |
232 | } |
233 | |
234 | return false; |
235 | } |
236 | // Page up || Page down |
237 | if( e.keyCode == 33 || e.keyCode == 34) { |
238 | var oldHoverIndex = allOptions.index(allOptions.filter('.hover')); |
239 | var newHoverIndex = -1; |
240 | var optionsPerPage = 8; // depends on css |
241 | // if we are moving up and there is a prev item then move |
242 | if(e.keyCode == 33 && oldHoverIndex > 0) { |
243 | newHoverIndex = oldHoverIndex - optionsPerPage; |
244 | if(newHoverIndex < 0) { |
245 | newHoverIndex = 0; |
246 | } |
247 | } |
248 | if(e.keyCode == 34 && oldHoverIndex < allOptions.length - 1) { |
249 | newHoverIndex = oldHoverIndex + optionsPerPage; |
250 | if(newHoverIndex > allOptions.length - 1) { |
251 | newHoverIndex = allOptions.length - 1; |
252 | } |
253 | } |
254 | // Highlight navigated option |
255 | $(allOptions).removeClass('hover'); // remove all highlights |
256 | $(allOptions.get(newHoverIndex)).addClass('hover'); // add the new highlight |
257 | lastNavTabKeyCheckbox = null; |
258 | // Adjust the viewport if necessary |
259 | adjustViewPort(optionsBox); |
260 | return false; |
261 | } |
262 | |
263 | // --- Selection with Keyboard --- |
264 | // Enter, Space |
265 | if( e.keyCode == 13 || e.keyCode == 32 ) { |
266 | var selectedCheckbox = optionsBox.find('LABEL.hover INPUT:checkbox'); |
267 | // Set the checkbox (and label class) |
268 | selectedCheckbox.attr('checked', !selectedCheckbox.attr('checked')).parent('LABEL').toggleClass('checked', selectedCheckbox.attr('checked')); |
269 | // Highlight selected option |
270 | // placeholder |
271 | // Refocus |
272 | // placeholder |
273 | updateSelected.call(optionsBox); |
274 | return false; |
275 | } |
276 | |
277 | // Any other standard keyboard character (try and match the first character of an option) |
278 | if( e.keyCode >= 33 && e.keyCode <= 126 ) { |
279 | // find the next matching item after the current hovered item |
280 | var match = optionsBox.find('LABEL:startsWith(' + String.fromCharCode(e.keyCode) + ')'); |
281 | |
282 | var currentHoverIndex = match.index(match.filter('LABEL.hover')); |
283 | |
284 | // filter the set to any items after the current hovered item |
285 | var afterHoverMatch = match.filter(function (index) { |
286 | return index > currentHoverIndex; |
287 | }); |
288 | |
289 | // if there were no item after the current hovered item then try using the full search results (filtered to the first one) |
290 | match = (afterHoverMatch.length >= 1 ? afterHoverMatch : match).filter("LABEL:first"); |
291 | |
292 | if(match.length == 1) { |
293 | // if we found a match then move the hover |
294 | // Highlight navigated option |
295 | $(allOptions).removeClass('hover'); // remove all highlights |
296 | match.addClass('hover'); // add the new highlight |
297 | lastNavTabKeyCheckbox = null; |
298 | |
299 | adjustViewPort(optionsBox); |
300 | } |
301 | } |
302 | // Prevent enter key from submitting form |
303 | if (e.keyCode == 13) { |
304 | return false; |
305 | } |
306 | }); |
307 | } |
308 | |
309 | // Scroll the viewport div if necessary |
310 | function adjustViewPort(optionsBox) { |
311 | // check for and move scrollbar down, content up |
312 | var hoverTop = optionsBox.find('LABEL.hover').position().top; |
313 | var hoverHeight = optionsBox.find('LABEL.hover').outerHeight(); |
314 | var selectionBottom = hoverTop + hoverHeight; |
315 | // The integer 18 is a manual approximation for typical scale, |
316 | // since there's extra padding at the top of the div.optionsBox |
317 | // which is not showing up anywhere quantitatively. |
318 | // Could use improvement. |
319 | var optionsHeight = optionsBox.outerHeight() + 18; |
320 | var optionsScrollTop = optionsBox.scrollTop(); |
321 | if(selectionBottom > optionsHeight) { |
322 | optionsBox.scrollTop(optionsScrollTop + selectionBottom - optionsHeight); |
323 | } |
324 | |
325 | // check for and move scrollbar up, content down |
326 | var hoveredTop = optionsBox.find('LABEL.hover').position().top; |
327 | var optionsTop = optionsBox.position().top; |
328 | optionsScrollTop = optionsBox.scrollTop(); |
329 | if(hoveredTop < optionsTop) { |
330 | optionsBox.scrollTop(optionsScrollTop + hoveredTop - optionsTop); |
331 | } |
332 | } |
333 | |
334 | // Update heading with the total number of selected items |
335 | function updateSelected() { |
336 | var optionsBox = $(this); |
337 | var multiSelectA = optionsBox.next('.multiSelectA'); |
338 | var i = 0; |
339 | var display = ''; |
340 | optionsBox.find('INPUT:checkbox').not('.selectAll, .optGroup').each( function() { |
341 | if ($(this).attr('checked')) { |
342 | i++; |
343 | display = display + |
344 | '<p class="selectedTag"><span class="selectedTag">' + |
345 | $(this).parent().text() + |
346 | '</span></p>'; |
347 | } |
348 | else { |
349 | selectAll = false; |
350 | } |
351 | }); |
352 | |
353 | if( i === 0 ) { |
354 | $("#selectedTagsHeading").html( $("#noTagsSelected").text() ); |
355 | $("#selectedTags").text(''); |
356 | } else { |
357 | $("#selectedTags").html( display ); |
358 | $("#selectedTagsHeading").html( $("#oneOrMoreTagsSelected").text().replace('%', i) ); |
359 | } |
360 | } |
361 | |
362 | $.extend($.fn, { |
363 | multiSelect: function() { |
364 | |
365 | // Initialize each optionsBox |
366 | $(this).each( function() { |
367 | var select = $(this); |
368 | var html = ''; |
369 | // Overflow-y: auto enables the scrollbar, like a multiple-select |
370 | html += '<div class="optionsBox" tabindex="9999" style="overflow-y: auto;"></div>'; |
371 | // Anchor originally used for dropdown. |
372 | // Will try to remove after refactoring to be more modular and testable with QUnit, |
373 | // although this element may need to stay to hold focus for mouse & arrow key navigation. |
374 | html += '<a href="javascript:;" class="multiSelectA" title="enable tag selector: tag selector is currently enabled"></a>'; |
375 | // display:block makes the blank area right of the text clickable, like a multiple-select |
376 | html += '<style type="text/css">.ArchetypesKeywordWidget label {display: block;}</style>'; |
377 | $(select).after(html); |
378 | |
379 | var optionsBox = $(select).next('.optionsBox'); |
380 | var multiSelectA = optionsBox.next('.multiSelectA'); |
381 | |
382 | // Serialize the select options into json options |
383 | var options = []; |
384 | $(select).children().each( function() { |
385 | if( $(this).val() !== '' ) { |
386 | options.push({ text: $(this).html(), value: $(this).val(), selected: $(this).attr('selected') }); |
387 | } |
388 | }); |
389 | |
390 | // Eliminate the original form element |
391 | $(select).remove(); |
392 | |
393 | // Add the id & name that was on the original select element to the new div |
394 | optionsBox.attr("id", $(select).attr("id")); |
395 | optionsBox.attr("name", $(select).attr("name")); |
396 | |
397 | // Build the dropdown options |
398 | buildOptions.call(optionsBox, options); |
399 | |
400 | }); |
401 | } |
402 | |
403 | }); |
404 | |
405 | // add a new ":startsWith" search filter |
406 | $.expr[":"].startsWith = function(el, i, m) { |
407 | var search = m[3]; |
408 | if (!search) { |
409 | return false; |
410 | } |
411 | return eval("/^[/s]*" + search + "/i").test($(el).text()); |
412 | }; |
413 | |
414 | })(jQuery); |