Revision 1261:4f5d10466283 plugins

View differences:

plugins/redmine_tags/assets/javascripts/tag-it.js
28 28

  
29 29
    $.widget('ui.tagit', {
30 30
        options: {
31
            itemName          : 'item',
31
            allowDuplicates   : false,
32
            caseSensitive     : true,
32 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`.
33 40
            availableTags     : [],
34
            tagSource         : null,
35
            removeConfirmation: false,
36
            caseSensitive     : true,
37
            placeholderText   : null,
38 41

  
39
            // When enabled, quotes are not neccesary
40
            // for inputting multi-word tags.
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.
41 52
            allowSpaces: false,
42 53

  
43
            // Whether to animate tag removals or not.
44
            animate: true,
45

  
46 54
            // The below options are for using a single field instead of several
47 55
            // for our form values.
48 56
            //
......
52 60
            //
53 61
            // The easiest way to use singleField is to just instantiate tag-it
54 62
            // on an INPUT element, in which case singleField is automatically
55
            // set to true, and singleFieldNode is set to that element. This 
63
            // set to true, and singleFieldNode is set to that element. This
56 64
            // way, you don't need to fiddle with these options.
57 65
            singleField: false,
58 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.
59 69
            singleFieldDelimiter: ',',
60 70

  
61 71
            // Set this to an input DOM node to use an existing form field.
......
64 74
            // delimited by singleFieldDelimiter.
65 75
            //
66 76
            // If this is not set, we create an input node for it,
67
            // with the name given in settings.fieldName, 
68
            // ignoring settings.itemName.
77
            // with the name given in settings.fieldName.
69 78
            singleFieldNode: null,
70 79

  
80
            // Whether to animate tag removals or not.
81
            animate: true,
82

  
71 83
            // Optionally set a tabindex attribute on the input that gets
72 84
            // created for tag-it.
73 85
            tabIndex: null,
74 86

  
87
            // Event callbacks.
88
            beforeTagAdded      : null,
89
            afterTagAdded       : null,
75 90

  
76
            // Event callbacks.
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.
77 103
            onTagAdded  : null,
78 104
            onTagRemoved: null,
79
            onTagClicked: null
105
            // `autocomplete.source` is the replacement for tagSource.
106
            tagSource: null
107
            // Do not use the above deprecated options.
80 108
        },
81 109

  
82

  
83 110
        _create: function() {
84 111
            // for handling static scoping inside callbacks
85 112
            var that = this;
......
97 124
                this.tagList = this.element.find('ul, ol').andSelf().last();
98 125
            }
99 126

  
100
            this._tagInput = $('<input type="text" />').addClass('ui-widget-content');
127
            this.tagInput = $('<input type="text" />').addClass('ui-widget-content');
128

  
129
            if (this.options.readOnly) this.tagInput.attr('disabled', 'disabled');
130

  
101 131
            if (this.options.tabIndex) {
102
                this._tagInput.attr('tabindex', this.options.tabIndex);
103
            }
104
            if (this.options.placeholderText) {
105
                this._tagInput.attr('placeholder', this.options.placeholderText);
132
                this.tagInput.attr('tabindex', this.options.tabIndex);
106 133
            }
107 134

  
108
            this.options.tagSource = this.options.tagSource || function(search, showChoices) {
109
                var filter = search.term.toLowerCase();
110
                var choices = $.grep(this.options.availableTags, function(element) {
111
                    // Only match autocomplete options that begin with the search term.
112
                    // (Case insensitive.)
113
                    return (element.toLowerCase().indexOf(filter) === 0);
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
                    showChoices(this._subtractArray(choices, this.assignedTags()));
148
                };
149
            }
150

  
151
            if (this.options.showAutocompleteOnFocus) {
152
                this.tagInput.focus(function(event, ui) {
153
                    that._showAutocomplete();
114 154
                });
115
                showChoices(this._subtractArray(choices, this.assignedTags()));
116
            };
117 155

  
118
            // Bind tagSource callback functions to this context.
156
                if (typeof this.options.autocomplete.minLength === 'undefined') {
157
                    this.options.autocomplete.minLength = 0;
158
                }
159
            }
160

  
161
            // Bind autocomplete.source callback functions to this context.
162
            if ($.isFunction(this.options.autocomplete.source)) {
163
                this.options.autocomplete.source = $.proxy(this.options.autocomplete.source, this);
164
            }
165

  
166
            // DEPRECATED.
119 167
            if ($.isFunction(this.options.tagSource)) {
120 168
                this.options.tagSource = $.proxy(this.options.tagSource, this);
121 169
            }
......
124 172
                .addClass('tagit')
125 173
                .addClass('ui-widget ui-widget-content ui-corner-all')
126 174
                // Create the input field.
127
                .append($('<li class="tagit-new"></li>').append(this._tagInput))
175
                .append($('<li class="tagit-new"></li>').append(this.tagInput))
128 176
                .click(function(e) {
129 177
                    var target = $(e.target);
130 178
                    if (target.hasClass('tagit-label')) {
131
                        that._trigger('onTagClicked', e, target.closest('.tagit-choice'));
179
                        var tag = target.closest('.tagit-choice');
180
                        if (!tag.hasClass('removed')) {
181
                            that._trigger('onTagClicked', e, {tag: tag, tagLabel: that.tagLabel(tag)});
182
                        }
132 183
                    } else {
133 184
                        // Sets the focus() to the input field, if the user
134 185
                        // clicks anywhere inside the UL. This is needed
135 186
                        // because the input field needs to be of a small size.
136
                        that._tagInput.focus();
187
                        that.tagInput.focus();
137 188
                    }
138 189
                });
139 190

  
140
            // Add existing tags from the list, if any.
141
            this.tagList.children('li').each(function() {
142
                if (!$(this).hasClass('tagit-new')) {
143
                    that.createTag($(this).html(), $(this).attr('class'));
144
                    $(this).remove();
145
                }
146
            });
147

  
148 191
            // Single field support.
192
            var addedExistingFromSingleFieldNode = false;
149 193
            if (this.options.singleField) {
150 194
                if (this.options.singleFieldNode) {
151 195
                    // Add existing tags from the input field.
......
153 197
                    var tags = node.val().split(this.options.singleFieldDelimiter);
154 198
                    node.val('');
155 199
                    $.each(tags, function(index, tag) {
156
                        that.createTag(tag);
200
                        that.createTag(tag, null, true);
201
                        addedExistingFromSingleFieldNode = true;
157 202
                    });
158 203
                } else {
159 204
                    // Create our single field input after our list.
160
                    this.options.singleFieldNode = this.tagList.after('<input type="hidden" style="display:none;" value="" name="' + this.options.fieldName + '" />');
205
                    this.options.singleFieldNode = $('<input type="hidden" style="display:none;" value="" name="' + this.options.fieldName + '" />');
206
                    this.tagList.after(this.options.singleFieldNode);
161 207
                }
162 208
            }
163 209

  
210
            // Add existing tags from the list, if any.
211
            if (!addedExistingFromSingleFieldNode) {
212
                this.tagList.children('li').each(function() {
213
                    if (!$(this).hasClass('tagit-new')) {
214
                        that.createTag($(this).text(), $(this).attr('class'), true);
215
                        $(this).remove();
216
                    }
217
                });
218
            }
219

  
164 220
            // Events.
165
            this._tagInput
221
            this.tagInput
166 222
                .keydown(function(event) {
167 223
                    // Backspace is not detected within a keypress, so it must use keydown.
168
                    if (event.which == $.ui.keyCode.BACKSPACE && that._tagInput.val() === '') {
224
                    if (event.which == $.ui.keyCode.BACKSPACE && that.tagInput.val() === '') {
169 225
                        var tag = that._lastTag();
170 226
                        if (!that.options.removeConfirmation || tag.hasClass('remove')) {
171 227
                            // When backspace is pressed, the last tag is deleted.
......
179 235

  
180 236
                    // Comma/Space/Enter are all valid delimiters for new tags,
181 237
                    // except when there is an open quote or if setting allowSpaces = true.
182
                    // Tab will also create a tag, unless the tag input is empty, in which case it isn't caught.
238
                    // Tab will also create a tag, unless the tag input is empty,
239
                    // in which case it isn't caught.
183 240
                    if (
184
                        event.which == $.ui.keyCode.COMMA ||
185
                        event.which == $.ui.keyCode.ENTER ||
241
                        event.which === $.ui.keyCode.COMMA ||
242
                        event.which === $.ui.keyCode.ENTER ||
186 243
                        (
187 244
                            event.which == $.ui.keyCode.TAB &&
188
                            that._tagInput.val() !== ''
245
                            that.tagInput.val() !== ''
189 246
                        ) ||
190 247
                        (
191 248
                            event.which == $.ui.keyCode.SPACE &&
192 249
                            that.options.allowSpaces !== true &&
193 250
                            (
194
                                $.trim(that._tagInput.val()).replace( /^s*/, '' ).charAt(0) != '"' ||
251
                                $.trim(that.tagInput.val()).replace( /^s*/, '' ).charAt(0) != '"' ||
195 252
                                (
196
                                    $.trim(that._tagInput.val()).charAt(0) == '"' &&
197
                                    $.trim(that._tagInput.val()).charAt($.trim(that._tagInput.val()).length - 1) == '"' &&
198
                                    $.trim(that._tagInput.val()).length - 1 !== 0
253
                                    $.trim(that.tagInput.val()).charAt(0) == '"' &&
254
                                    $.trim(that.tagInput.val()).charAt($.trim(that.tagInput.val()).length - 1) == '"' &&
255
                                    $.trim(that.tagInput.val()).length - 1 !== 0
199 256
                                )
200 257
                            )
201 258
                        )
202 259
                    ) {
203
                        event.preventDefault();
260
                        // Enter submits the form if there's no text in the input.
261
                        if (!(event.which === $.ui.keyCode.ENTER && that.tagInput.val() === '')) {
262
                            event.preventDefault();
263
                        }
264

  
204 265
                        that.createTag(that._cleanedInput());
205 266

  
206 267
                        // The autocomplete doesn't close automatically when TAB is pressed.
207 268
                        // So let's ensure that it closes.
208
                        that._tagInput.autocomplete('close');
269
                        that.tagInput.autocomplete('close');
209 270
                    }
210 271
                }).blur(function(e){
211
                    // Create a tag when the element loses focus (unless it's empty).
212
                    that.createTag(that._cleanedInput());
272
                    // Create a tag when the element loses focus.
273
                    // If autocomplete is enabled and suggestion was clicked, don't add it.
274
                    if (!that.tagInput.data('autocomplete-open')) {
275
                        that.createTag(that._cleanedInput());
276
                    }
213 277
                });
214
                
215 278

  
216 279
            // Autocomplete.
217
            if (this.options.availableTags || this.options.tagSource) {
218
                this._tagInput.autocomplete({
219
                    source: this.options.tagSource,
280
            if (this.options.availableTags || this.options.tagSource || this.options.autocomplete.source) {
281
                var autocompleteOptions = {
220 282
                    select: function(event, ui) {
221
                        // Delete the last tag if we autocomplete something despite the input being empty
222
                        // This happens because the input's blur event causes the tag to be created when
223
                        // the user clicks an autocomplete item.
224
                        // The only artifact of this is that while the user holds down the mouse button
225
                        // on the selected autocomplete item, a tag is shown with the pre-autocompleted text,
226
                        // and is changed to the autocompleted text upon mouseup.
227
                        if (that._tagInput.val() === '') {
228
                            that.removeTag(that._lastTag(), false);
229
                        }
230 283
                        that.createTag(ui.item.value);
231 284
                        // Preventing the tag input to be updated with the chosen value.
232 285
                        return false;
233 286
                    }
287
                };
288
                $.extend(autocompleteOptions, this.options.autocomplete);
289

  
290
                // tagSource is deprecated, but takes precedence here since autocomplete.source is set by default,
291
                // while tagSource is left null by default.
292
                autocompleteOptions.source = this.options.tagSource || autocompleteOptions.source;
293

  
294
                this.tagInput.autocomplete(autocompleteOptions).bind('autocompleteopen', function(event, ui) {
295
                    that.tagInput.data('autocomplete-open', true);
296
                }).bind('autocompleteclose', function(event, ui) {
297
                    that.tagInput.data('autocomplete-open', false)
234 298
                });
235 299
            }
236 300
        },
237 301

  
238 302
        _cleanedInput: function() {
239 303
            // Returns the contents of the tag input, cleaned and ready to be passed to createTag
240
            return $.trim(this._tagInput.val().replace(/^"(.*)"$/, '$1'));
304
            return $.trim(this.tagInput.val().replace(/^"(.*)"$/, '$1'));
241 305
        },
242 306

  
243 307
        _lastTag: function() {
244
            return this.tagList.children('.tagit-choice:last');
308
            return this.tagList.find('.tagit-choice:last:not(.removed)');
309
        },
310

  
311
        _tags: function() {
312
            return this.tagList.find('.tagit-choice:not(.removed)');
245 313
        },
246 314

  
247 315
        assignedTags: function() {
......
254 322
                    tags = [];
255 323
                }
256 324
            } else {
257
                this.tagList.children('.tagit-choice').each(function() {
325
                this._tags().each(function() {
258 326
                    tags.push(that.tagLabel(this));
259 327
                });
260 328
            }
......
263 331

  
264 332
        _updateSingleTagsField: function(tags) {
265 333
            // Takes a list of tag string values, updates this.options.singleFieldNode.val to the tags delimited by this.options.singleFieldDelimiter
266
            $(this.options.singleFieldNode).val(tags.join(this.options.singleFieldDelimiter));
334
            $(this.options.singleFieldNode).val(tags.join(this.options.singleFieldDelimiter)).trigger('change');
267 335
        },
268 336

  
269 337
        _subtractArray: function(a1, a2) {
......
279 347
        tagLabel: function(tag) {
280 348
            // Returns the tag's string label.
281 349
            if (this.options.singleField) {
282
                return $(tag).children('.tagit-label').text();
350
                return $(tag).find('.tagit-label:first').text();
283 351
            } else {
284
                return $(tag).children('input').val();
352
                return $(tag).find('input:first').val();
285 353
            }
286 354
        },
287 355

  
288
        _isNew: function(value) {
356
        _showAutocomplete: function() {
357
            this.tagInput.autocomplete('search', '');
358
        },
359

  
360
        _findTagByLabel: function(name) {
289 361
            var that = this;
290
            var isNew = true;
291
            this.tagList.children('.tagit-choice').each(function(i) {
292
                if (that._formatStr(value) == that._formatStr(that.tagLabel(this))) {
293
                    isNew = false;
362
            var tag = null;
363
            this._tags().each(function(i) {
364
                if (that._formatStr(name) == that._formatStr(that.tagLabel(this))) {
365
                    tag = $(this);
294 366
                    return false;
295 367
                }
296 368
            });
297
            return isNew;
369
            return tag;
370
        },
371

  
372
        _isNew: function(name) {
373
            return !this._findTagByLabel(name);
298 374
        },
299 375

  
300 376
        _formatStr: function(str) {
......
304 380
            return $.trim(str.toLowerCase());
305 381
        },
306 382

  
307
        createTag: function(value, additionalClass) {
383
        _effectExists: function(name) {
384
            return Boolean($.effects && ($.effects[name] || ($.effects.effect && $.effects.effect[name])));
385
        },
386

  
387
        createTag: function(value, additionalClass, duringInitialization) {
308 388
            var that = this;
309
            // Automatically trims the value of leading and trailing whitespace.
389

  
310 390
            value = $.trim(value);
311 391

  
312
            if (!this._isNew(value) || value === '') {
392
            if(this.options.preprocessTag) {
393
                value = this.options.preprocessTag(value);
394
            }
395

  
396
            if (value === '') {
397
                return false;
398
            }
399

  
400
            if (!this.options.allowDuplicates && !this._isNew(value)) {
401
                var existingTag = this._findTagByLabel(value);
402
                if (this._trigger('onTagExists', null, {
403
                    existingTag: existingTag,
404
                    duringInitialization: duringInitialization
405
                }) !== false) {
406
                    if (this._effectExists('highlight')) {
407
                        existingTag.effect('highlight');
408
                    }
409
                }
410
                return false;
411
            }
412

  
413
            if (this.options.tagLimit && this._tags().length >= this.options.tagLimit) {
414
                this._trigger('onTagLimitExceeded', null, {duringInitialization: duringInitialization});
313 415
                return false;
314 416
            }
315 417

  
......
321 423
                .addClass(additionalClass)
322 424
                .append(label);
323 425

  
324
            // Button for removing the tag.
325
            var removeTagIcon = $('<span></span>')
326
                .addClass('ui-icon ui-icon-close');
327
            var removeTag = $('<a><span class="text-icon">\xd7</span></a>') // \xd7 is an X
328
                .addClass('tagit-close')
329
                .append(removeTagIcon)
330
                .click(function(e) {
331
                    // Removes a tag when the little 'x' is clicked.
332
                    that.removeTag(tag);
333
                });
334
            tag.append(removeTag);
426
            if (this.options.readOnly){
427
                tag.addClass('tagit-choice-read-only');
428
            } else {
429
                tag.addClass('tagit-choice-editable');
430
                // Button for removing the tag.
431
                var removeTagIcon = $('<span></span>')
432
                    .addClass('ui-icon ui-icon-close');
433
                var removeTag = $('<a><span class="text-icon">\xd7</span></a>') // \xd7 is an X
434
                    .addClass('tagit-close')
435
                    .append(removeTagIcon)
436
                    .click(function(e) {
437
                        // Removes a tag when the little 'x' is clicked.
438
                        that.removeTag(tag);
439
                    });
440
                tag.append(removeTag);
441
            }
335 442

  
336 443
            // Unless options.singleField is set, each tag has a hidden input field inline.
444
            if (!this.options.singleField) {
445
                var escapedValue = label.html();
446
                tag.append('<input type="hidden" style="display:none;" value="' + escapedValue + '" name="' + this.options.fieldName + '" />');
447
            }
448

  
449
            if (this._trigger('beforeTagAdded', null, {
450
                tag: tag,
451
                tagLabel: this.tagLabel(tag),
452
                duringInitialization: duringInitialization
453
            }) === false) {
454
                return;
455
            }
456

  
337 457
            if (this.options.singleField) {
338 458
                var tags = this.assignedTags();
339 459
                tags.push(value);
340 460
                this._updateSingleTagsField(tags);
341
            } else {
342
                var escapedValue = label.html();
343
                tag.append('<input type="hidden" style="display:none;" value="' + escapedValue + '" name="' + this.options.itemName + '[' + this.options.fieldName + '][]" />');
344 461
            }
345 462

  
463
            // DEPRECATED.
346 464
            this._trigger('onTagAdded', null, tag);
347 465

  
348
            // Cleaning the input.
349
            this._tagInput.val('');
466
            this.tagInput.val('');
350 467

  
351
            // insert tag
352
            this._tagInput.parent().before(tag);
468
            // Insert tag.
469
            this.tagInput.parent().before(tag);
470

  
471
            this._trigger('afterTagAdded', null, {
472
                tag: tag,
473
                tagLabel: this.tagLabel(tag),
474
                duringInitialization: duringInitialization
475
            });
476

  
477
            if (this.options.showAutocompleteOnFocus && !duringInitialization) {
478
                setTimeout(function () { that._showAutocomplete(); }, 0);
479
            }
353 480
        },
354
        
481

  
355 482
        removeTag: function(tag, animate) {
356
            animate = animate || this.options.animate;
483
            animate = typeof animate === 'undefined' ? this.options.animate : animate;
357 484

  
358 485
            tag = $(tag);
359 486

  
487
            // DEPRECATED.
360 488
            this._trigger('onTagRemoved', null, tag);
361 489

  
490
            if (this._trigger('beforeTagRemoved', null, {tag: tag, tagLabel: this.tagLabel(tag)}) === false) {
491
                return;
492
            }
493

  
362 494
            if (this.options.singleField) {
363 495
                var tags = this.assignedTags();
364 496
                var removedTagLabel = this.tagLabel(tag);
......
367 499
                });
368 500
                this._updateSingleTagsField(tags);
369 501
            }
370
            // Animate the removal.
502

  
371 503
            if (animate) {
372
                tag.fadeOut('fast').hide('blind', {direction: 'horizontal'}, 'fast', function(){
504
                tag.addClass('removed'); // Excludes this tag from _tags.
505
                var hide_args = this._effectExists('blind') ? ['blind', {direction: 'horizontal'}, 'fast'] : ['fast'];
506

  
507
                var thisTag = this;
508
                hide_args.push(function() {
373 509
                    tag.remove();
374
                }).dequeue();
510
                    thisTag._trigger('afterTagRemoved', null, {tag: tag, tagLabel: thisTag.tagLabel(tag)});
511
                });
512

  
513
                tag.fadeOut('fast').hide.apply(tag, hide_args).dequeue();
375 514
            } else {
376 515
                tag.remove();
516
                this._trigger('afterTagRemoved', null, {tag: tag, tagLabel: this.tagLabel(tag)});
377 517
            }
518

  
519
        },
520

  
521
        removeTagByLabel: function(tagLabel, animate) {
522
            var toRemove = this._findTagByLabel(tagLabel);
523
            if (!toRemove) {
524
                throw "No such tag exists with the name '" + tagLabel + "'";
525
            }
526
            this.removeTag(toRemove, animate);
378 527
        },
379 528

  
380 529
        removeAll: function() {
381 530
            // Removes all tags.
382 531
            var that = this;
383
            this.tagList.children('.tagit-choice').each(function(index, tag) {
532
            this._tags().each(function(index, tag) {
384 533
                that.removeTag(tag, false);
385 534
            });
386 535
        }
387 536

  
388 537
    });
389

  
390
})(jQuery);
391

  
392

  
538
})(jQuery);

Also available in: Unified diff