|
1 /* |
|
2 * jQuery UI Tag-it! |
|
3 * |
|
4 * @version v2.0 (06/2011) |
|
5 * |
|
6 * Copyright 2011, Levy Carneiro Jr. |
|
7 * Released under the MIT license. |
|
8 * http://aehlke.github.com/tag-it/LICENSE |
|
9 * |
|
10 * Homepage: |
|
11 * http://aehlke.github.com/tag-it/ |
|
12 * |
|
13 * Authors: |
|
14 * Levy Carneiro Jr. |
|
15 * Martin Rehfeld |
|
16 * Tobias Schmidt |
|
17 * Skylar Challand |
|
18 * Alex Ehlke |
|
19 * |
|
20 * Maintainer: |
|
21 * Alex Ehlke - Twitter: @aehlke |
|
22 * |
|
23 * Dependencies: |
|
24 * jQuery v1.4+ |
|
25 * jQuery UI v1.8+ |
|
26 */ |
|
27 (function($) { |
|
28 |
|
29 $.widget('ui.tagit', { |
|
30 options: { |
|
31 allowDuplicates : false, |
|
32 caseSensitive : true, |
|
33 fieldName : 'tags', |
|
34 placeholderText : null, // Sets `placeholder` attr on input field. |
|
35 readOnly : false, // Disables editing. |
|
36 removeConfirmation: false, // Require confirmation to remove tags. |
|
37 tagLimit : null, // Max number of tags allowed (null for unlimited). |
|
38 |
|
39 // Used for autocomplete, unless you override `autocomplete.source`. |
|
40 availableTags : [], |
|
41 |
|
42 // Use to override or add any options to the autocomplete widget. |
|
43 // |
|
44 // By default, autocomplete.source will map to availableTags, |
|
45 // unless overridden. |
|
46 autocomplete: {}, |
|
47 |
|
48 // Shows autocomplete before the user even types anything. |
|
49 showAutocompleteOnFocus: false, |
|
50 |
|
51 // When enabled, quotes are unneccesary for inputting multi-word tags. |
|
52 allowSpaces: false, |
|
53 |
|
54 // The below options are for using a single field instead of several |
|
55 // for our form values. |
|
56 // |
|
57 // When enabled, will use a single hidden field for the form, |
|
58 // rather than one per tag. It will delimit tags in the field |
|
59 // with singleFieldDelimiter. |
|
60 // |
|
61 // The easiest way to use singleField is to just instantiate tag-it |
|
62 // on an INPUT element, in which case singleField is automatically |
|
63 // set to true, and singleFieldNode is set to that element. This |
|
64 // way, you don't need to fiddle with these options. |
|
65 singleField: false, |
|
66 |
|
67 // This is just used when preloading data from the field, and for |
|
68 // populating the field with delimited tags as the user adds them. |
|
69 singleFieldDelimiter: ',', |
|
70 |
|
71 // Set this to an input DOM node to use an existing form field. |
|
72 // Any text in it will be erased on init. But it will be |
|
73 // populated with the text of tags as they are created, |
|
74 // delimited by singleFieldDelimiter. |
|
75 // |
|
76 // If this is not set, we create an input node for it, |
|
77 // with the name given in settings.fieldName. |
|
78 singleFieldNode: null, |
|
79 |
|
80 // Whether to animate tag removals or not. |
|
81 animate: true, |
|
82 |
|
83 // Optionally set a tabindex attribute on the input that gets |
|
84 // created for tag-it. |
|
85 tabIndex: null, |
|
86 |
|
87 // Event callbacks. |
|
88 beforeTagAdded : null, |
|
89 afterTagAdded : null, |
|
90 |
|
91 beforeTagRemoved : null, |
|
92 afterTagRemoved : null, |
|
93 |
|
94 onTagClicked : null, |
|
95 onTagLimitExceeded : null, |
|
96 |
|
97 |
|
98 // DEPRECATED: |
|
99 // |
|
100 // /!\ These event callbacks are deprecated and WILL BE REMOVED at some |
|
101 // point in the future. They're here for backwards-compatibility. |
|
102 // Use the above before/after event callbacks instead. |
|
103 onTagAdded : null, |
|
104 onTagRemoved: null, |
|
105 // `autocomplete.source` is the replacement for tagSource. |
|
106 tagSource: null |
|
107 // Do not use the above deprecated options. |
|
108 }, |
|
109 |
|
110 _create: function() { |
|
111 // for handling static scoping inside callbacks |
|
112 var that = this; |
|
113 |
|
114 // There are 2 kinds of DOM nodes this widget can be instantiated on: |
|
115 // 1. UL, OL, or some element containing either of these. |
|
116 // 2. INPUT, in which case 'singleField' is overridden to true, |
|
117 // a UL is created and the INPUT is hidden. |
|
118 if (this.element.is('input')) { |
|
119 this.tagList = $('<ul></ul>').insertAfter(this.element); |
|
120 this.options.singleField = true; |
|
121 this.options.singleFieldNode = this.element; |
|
122 this.element.css('display', 'none'); |
|
123 } else { |
|
124 this.tagList = this.element.find('ul, ol').andSelf().last(); |
|
125 } |
|
126 |
|
127 this.tagInput = $('<input type="text" />').addClass('ui-widget-content'); |
|
128 |
|
129 if (this.options.readOnly) this.tagInput.attr('disabled', 'disabled'); |
|
130 |
|
131 if (this.options.tabIndex) { |
|
132 this.tagInput.attr('tabindex', this.options.tabIndex); |
|
133 } |
|
134 |
|
135 if (this.options.placeholderText) { |
|
136 this.tagInput.attr('placeholder', this.options.placeholderText); |
|
137 } |
|
138 |
|
139 if (!this.options.autocomplete.source) { |
|
140 this.options.autocomplete.source = function(search, showChoices) { |
|
141 var filter = search.term.toLowerCase(); |
|
142 var choices = $.grep(this.options.availableTags, function(element) { |
|
143 // Only match autocomplete options that begin with the search term. |
|
144 // (Case insensitive.) |
|
145 return (element.toLowerCase().indexOf(filter) === 0); |
|
146 }); |
|
147 if (!this.options.allowDuplicates) { |
|
148 choices = this._subtractArray(choices, this.assignedTags()); |
|
149 } |
|
150 showChoices(choices); |
|
151 }; |
|
152 } |
|
153 |
|
154 if (this.options.showAutocompleteOnFocus) { |
|
155 this.tagInput.focus(function(event, ui) { |
|
156 that._showAutocomplete(); |
|
157 }); |
|
158 |
|
159 if (typeof this.options.autocomplete.minLength === 'undefined') { |
|
160 this.options.autocomplete.minLength = 0; |
|
161 } |
|
162 } |
|
163 |
|
164 // Bind autocomplete.source callback functions to this context. |
|
165 if ($.isFunction(this.options.autocomplete.source)) { |
|
166 this.options.autocomplete.source = $.proxy(this.options.autocomplete.source, this); |
|
167 } |
|
168 |
|
169 // DEPRECATED. |
|
170 if ($.isFunction(this.options.tagSource)) { |
|
171 this.options.tagSource = $.proxy(this.options.tagSource, this); |
|
172 } |
|
173 |
|
174 this.tagList |
|
175 .addClass('tagit') |
|
176 .addClass('ui-widget ui-widget-content ui-corner-all') |
|
177 // Create the input field. |
|
178 .append($('<li class="tagit-new"></li>').append(this.tagInput)) |
|
179 .click(function(e) { |
|
180 var target = $(e.target); |
|
181 if (target.hasClass('tagit-label')) { |
|
182 var tag = target.closest('.tagit-choice'); |
|
183 if (!tag.hasClass('removed')) { |
|
184 that._trigger('onTagClicked', e, {tag: tag, tagLabel: that.tagLabel(tag)}); |
|
185 } |
|
186 } else { |
|
187 // Sets the focus() to the input field, if the user |
|
188 // clicks anywhere inside the UL. This is needed |
|
189 // because the input field needs to be of a small size. |
|
190 that.tagInput.focus(); |
|
191 } |
|
192 }); |
|
193 |
|
194 // Single field support. |
|
195 var addedExistingFromSingleFieldNode = false; |
|
196 if (this.options.singleField) { |
|
197 if (this.options.singleFieldNode) { |
|
198 // Add existing tags from the input field. |
|
199 var node = $(this.options.singleFieldNode); |
|
200 var tags = node.val().split(this.options.singleFieldDelimiter); |
|
201 node.val(''); |
|
202 $.each(tags, function(index, tag) { |
|
203 that.createTag(tag, null, true); |
|
204 addedExistingFromSingleFieldNode = true; |
|
205 }); |
|
206 } else { |
|
207 // Create our single field input after our list. |
|
208 this.options.singleFieldNode = $('<input type="hidden" style="display:none;" value="" name="' + this.options.fieldName + '" />'); |
|
209 this.tagList.after(this.options.singleFieldNode); |
|
210 } |
|
211 } |
|
212 |
|
213 // Add existing tags from the list, if any. |
|
214 if (!addedExistingFromSingleFieldNode) { |
|
215 this.tagList.children('li').each(function() { |
|
216 if (!$(this).hasClass('tagit-new')) { |
|
217 that.createTag($(this).text(), $(this).attr('class'), true); |
|
218 $(this).remove(); |
|
219 } |
|
220 }); |
|
221 } |
|
222 |
|
223 // Events. |
|
224 this.tagInput |
|
225 .keydown(function(event) { |
|
226 // Backspace is not detected within a keypress, so it must use keydown. |
|
227 if (event.which == $.ui.keyCode.BACKSPACE && that.tagInput.val() === '') { |
|
228 var tag = that._lastTag(); |
|
229 if (!that.options.removeConfirmation || tag.hasClass('remove')) { |
|
230 // When backspace is pressed, the last tag is deleted. |
|
231 that.removeTag(tag); |
|
232 } else if (that.options.removeConfirmation) { |
|
233 tag.addClass('remove ui-state-highlight'); |
|
234 } |
|
235 } else if (that.options.removeConfirmation) { |
|
236 that._lastTag().removeClass('remove ui-state-highlight'); |
|
237 } |
|
238 |
|
239 // Comma/Space/Enter are all valid delimiters for new tags, |
|
240 // except when there is an open quote or if setting allowSpaces = true. |
|
241 // Tab will also create a tag, unless the tag input is empty, |
|
242 // in which case it isn't caught. |
|
243 if ( |
|
244 event.which === $.ui.keyCode.COMMA || |
|
245 event.which === $.ui.keyCode.ENTER || |
|
246 ( |
|
247 event.which == $.ui.keyCode.TAB && |
|
248 that.tagInput.val() !== '' |
|
249 ) || |
|
250 ( |
|
251 event.which == $.ui.keyCode.SPACE && |
|
252 that.options.allowSpaces !== true && |
|
253 ( |
|
254 $.trim(that.tagInput.val()).replace( /^s*/, '' ).charAt(0) != '"' || |
|
255 ( |
|
256 $.trim(that.tagInput.val()).charAt(0) == '"' && |
|
257 $.trim(that.tagInput.val()).charAt($.trim(that.tagInput.val()).length - 1) == '"' && |
|
258 $.trim(that.tagInput.val()).length - 1 !== 0 |
|
259 ) |
|
260 ) |
|
261 ) |
|
262 ) { |
|
263 // Enter submits the form if there's no text in the input. |
|
264 if (!(event.which === $.ui.keyCode.ENTER && that.tagInput.val() === '')) { |
|
265 event.preventDefault(); |
|
266 } |
|
267 |
|
268 // Autocomplete will create its own tag from a selection and close automatically. |
|
269 if (!that.tagInput.data('autocomplete-open')) { |
|
270 that.createTag(that._cleanedInput()); |
|
271 } |
|
272 } |
|
273 }).blur(function(e){ |
|
274 // Create a tag when the element loses focus. |
|
275 // If autocomplete is enabled and suggestion was clicked, don't add it. |
|
276 if (!that.tagInput.data('autocomplete-open')) { |
|
277 that.createTag(that._cleanedInput()); |
|
278 } |
|
279 }); |
|
280 |
|
281 // Autocomplete. |
|
282 if (this.options.availableTags || this.options.tagSource || this.options.autocomplete.source) { |
|
283 var autocompleteOptions = { |
|
284 select: function(event, ui) { |
|
285 that.createTag(ui.item.value); |
|
286 // Preventing the tag input to be updated with the chosen value. |
|
287 return false; |
|
288 } |
|
289 }; |
|
290 $.extend(autocompleteOptions, this.options.autocomplete); |
|
291 |
|
292 // tagSource is deprecated, but takes precedence here since autocomplete.source is set by default, |
|
293 // while tagSource is left null by default. |
|
294 autocompleteOptions.source = this.options.tagSource || autocompleteOptions.source; |
|
295 |
|
296 this.tagInput.autocomplete(autocompleteOptions).bind('autocompleteopen', function(event, ui) { |
|
297 that.tagInput.data('autocomplete-open', true); |
|
298 }).bind('autocompleteclose', function(event, ui) { |
|
299 that.tagInput.data('autocomplete-open', false) |
|
300 }); |
|
301 } |
|
302 }, |
|
303 |
|
304 _cleanedInput: function() { |
|
305 // Returns the contents of the tag input, cleaned and ready to be passed to createTag |
|
306 return $.trim(this.tagInput.val().replace(/^"(.*)"$/, '$1')); |
|
307 }, |
|
308 |
|
309 _lastTag: function() { |
|
310 return this.tagList.find('.tagit-choice:last:not(.removed)'); |
|
311 }, |
|
312 |
|
313 _tags: function() { |
|
314 return this.tagList.find('.tagit-choice:not(.removed)'); |
|
315 }, |
|
316 |
|
317 assignedTags: function() { |
|
318 // Returns an array of tag string values |
|
319 var that = this; |
|
320 var tags = []; |
|
321 if (this.options.singleField) { |
|
322 tags = $(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter); |
|
323 if (tags[0] === '') { |
|
324 tags = []; |
|
325 } |
|
326 } else { |
|
327 this._tags().each(function() { |
|
328 tags.push(that.tagLabel(this)); |
|
329 }); |
|
330 } |
|
331 return tags; |
|
332 }, |
|
333 |
|
334 _updateSingleTagsField: function(tags) { |
|
335 // Takes a list of tag string values, updates this.options.singleFieldNode.val to the tags delimited by this.options.singleFieldDelimiter |
|
336 $(this.options.singleFieldNode).val(tags.join(this.options.singleFieldDelimiter)).trigger('change'); |
|
337 }, |
|
338 |
|
339 _subtractArray: function(a1, a2) { |
|
340 var result = []; |
|
341 for (var i = 0; i < a1.length; i++) { |
|
342 if ($.inArray(a1[i], a2) == -1) { |
|
343 result.push(a1[i]); |
|
344 } |
|
345 } |
|
346 return result; |
|
347 }, |
|
348 |
|
349 tagLabel: function(tag) { |
|
350 // Returns the tag's string label. |
|
351 if (this.options.singleField) { |
|
352 return $(tag).find('.tagit-label:first').text(); |
|
353 } else { |
|
354 return $(tag).find('input:first').val(); |
|
355 } |
|
356 }, |
|
357 |
|
358 _showAutocomplete: function() { |
|
359 this.tagInput.autocomplete('search', ''); |
|
360 }, |
|
361 |
|
362 _findTagByLabel: function(name) { |
|
363 var that = this; |
|
364 var tag = null; |
|
365 this._tags().each(function(i) { |
|
366 if (that._formatStr(name) == that._formatStr(that.tagLabel(this))) { |
|
367 tag = $(this); |
|
368 return false; |
|
369 } |
|
370 }); |
|
371 return tag; |
|
372 }, |
|
373 |
|
374 _isNew: function(name) { |
|
375 return !this._findTagByLabel(name); |
|
376 }, |
|
377 |
|
378 _formatStr: function(str) { |
|
379 if (this.options.caseSensitive) { |
|
380 return str; |
|
381 } |
|
382 return $.trim(str.toLowerCase()); |
|
383 }, |
|
384 |
|
385 _effectExists: function(name) { |
|
386 return Boolean($.effects && ($.effects[name] || ($.effects.effect && $.effects.effect[name]))); |
|
387 }, |
|
388 |
|
389 createTag: function(value, additionalClass, duringInitialization) { |
|
390 var that = this; |
|
391 |
|
392 value = $.trim(value); |
|
393 |
|
394 if(this.options.preprocessTag) { |
|
395 value = this.options.preprocessTag(value); |
|
396 } |
|
397 |
|
398 if (value === '') { |
|
399 return false; |
|
400 } |
|
401 |
|
402 if (!this.options.allowDuplicates && !this._isNew(value)) { |
|
403 var existingTag = this._findTagByLabel(value); |
|
404 if (this._trigger('onTagExists', null, { |
|
405 existingTag: existingTag, |
|
406 duringInitialization: duringInitialization |
|
407 }) !== false) { |
|
408 if (this._effectExists('highlight')) { |
|
409 existingTag.effect('highlight'); |
|
410 } |
|
411 } |
|
412 return false; |
|
413 } |
|
414 |
|
415 if (this.options.tagLimit && this._tags().length >= this.options.tagLimit) { |
|
416 this._trigger('onTagLimitExceeded', null, {duringInitialization: duringInitialization}); |
|
417 return false; |
|
418 } |
|
419 |
|
420 var label = $(this.options.onTagClicked ? '<a class="tagit-label"></a>' : '<span class="tagit-label"></span>').text(value); |
|
421 |
|
422 // Create tag. |
|
423 var tag = $('<li></li>') |
|
424 .addClass('tagit-choice ui-widget-content ui-state-default ui-corner-all') |
|
425 .addClass(additionalClass) |
|
426 .append(label); |
|
427 |
|
428 if (this.options.readOnly){ |
|
429 tag.addClass('tagit-choice-read-only'); |
|
430 } else { |
|
431 tag.addClass('tagit-choice-editable'); |
|
432 // Button for removing the tag. |
|
433 var removeTagIcon = $('<span></span>') |
|
434 .addClass('ui-icon ui-icon-close'); |
|
435 var removeTag = $('<a><span class="text-icon">\xd7</span></a>') // \xd7 is an X |
|
436 .addClass('tagit-close') |
|
437 .append(removeTagIcon) |
|
438 .click(function(e) { |
|
439 // Removes a tag when the little 'x' is clicked. |
|
440 that.removeTag(tag); |
|
441 }); |
|
442 tag.append(removeTag); |
|
443 } |
|
444 |
|
445 // Unless options.singleField is set, each tag has a hidden input field inline. |
|
446 if (!this.options.singleField) { |
|
447 var escapedValue = label.html(); |
|
448 tag.append('<input type="hidden" style="display:none;" value="' + escapedValue + '" name="' + this.options.fieldName + '" />'); |
|
449 } |
|
450 |
|
451 if (this._trigger('beforeTagAdded', null, { |
|
452 tag: tag, |
|
453 tagLabel: this.tagLabel(tag), |
|
454 duringInitialization: duringInitialization |
|
455 }) === false) { |
|
456 return; |
|
457 } |
|
458 |
|
459 if (this.options.singleField) { |
|
460 var tags = this.assignedTags(); |
|
461 tags.push(value); |
|
462 this._updateSingleTagsField(tags); |
|
463 } |
|
464 |
|
465 // DEPRECATED. |
|
466 this._trigger('onTagAdded', null, tag); |
|
467 |
|
468 this.tagInput.val(''); |
|
469 |
|
470 // Insert tag. |
|
471 this.tagInput.parent().before(tag); |
|
472 |
|
473 this._trigger('afterTagAdded', null, { |
|
474 tag: tag, |
|
475 tagLabel: this.tagLabel(tag), |
|
476 duringInitialization: duringInitialization |
|
477 }); |
|
478 |
|
479 if (this.options.showAutocompleteOnFocus && !duringInitialization) { |
|
480 setTimeout(function () { that._showAutocomplete(); }, 0); |
|
481 } |
|
482 }, |
|
483 |
|
484 removeTag: function(tag, animate) { |
|
485 animate = typeof animate === 'undefined' ? this.options.animate : animate; |
|
486 |
|
487 tag = $(tag); |
|
488 |
|
489 // DEPRECATED. |
|
490 this._trigger('onTagRemoved', null, tag); |
|
491 |
|
492 if (this._trigger('beforeTagRemoved', null, {tag: tag, tagLabel: this.tagLabel(tag)}) === false) { |
|
493 return; |
|
494 } |
|
495 |
|
496 if (this.options.singleField) { |
|
497 var tags = this.assignedTags(); |
|
498 var removedTagLabel = this.tagLabel(tag); |
|
499 tags = $.grep(tags, function(el){ |
|
500 return el != removedTagLabel; |
|
501 }); |
|
502 this._updateSingleTagsField(tags); |
|
503 } |
|
504 |
|
505 if (animate) { |
|
506 tag.addClass('removed'); // Excludes this tag from _tags. |
|
507 var hide_args = this._effectExists('blind') ? ['blind', {direction: 'horizontal'}, 'fast'] : ['fast']; |
|
508 |
|
509 var thisTag = this; |
|
510 hide_args.push(function() { |
|
511 tag.remove(); |
|
512 thisTag._trigger('afterTagRemoved', null, {tag: tag, tagLabel: thisTag.tagLabel(tag)}); |
|
513 }); |
|
514 |
|
515 tag.fadeOut('fast').hide.apply(tag, hide_args).dequeue(); |
|
516 } else { |
|
517 tag.remove(); |
|
518 this._trigger('afterTagRemoved', null, {tag: tag, tagLabel: this.tagLabel(tag)}); |
|
519 } |
|
520 |
|
521 }, |
|
522 |
|
523 removeTagByLabel: function(tagLabel, animate) { |
|
524 var toRemove = this._findTagByLabel(tagLabel); |
|
525 if (!toRemove) { |
|
526 throw "No such tag exists with the name '" + tagLabel + "'"; |
|
527 } |
|
528 this.removeTag(toRemove, animate); |
|
529 }, |
|
530 |
|
531 removeAll: function() { |
|
532 // Removes all tags. |
|
533 var that = this; |
|
534 this._tags().each(function(index, tag) { |
|
535 that.removeTag(tag, false); |
|
536 }); |
|
537 } |
|
538 |
|
539 }); |
|
540 })(jQuery); |
|
541 |