Add content selection before removing content
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / js / WoltLabSuite / Core / Language / Input.js
1 /**
2 * I18n interface for input and textarea fields.
3 *
4 * @author Alexander Ebert
5 * @copyright 2001-2018 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 * @module WoltLabSuite/Core/Language/Input
8 */
9 define(['Core', 'Dictionary', 'Language', 'ObjectMap', 'StringUtil', 'Dom/Traverse', 'Dom/Util', 'Ui/SimpleDropdown'], function(Core, Dictionary, Language, ObjectMap, StringUtil, DomTraverse, DomUtil, UiSimpleDropdown) {
10 "use strict";
11
12 var _elements = new Dictionary();
13 var _didInit = false;
14 var _forms = new ObjectMap();
15 var _values = new Dictionary();
16
17 var _callbackDropdownToggle = null;
18 var _callbackSubmit = null;
19
20 /**
21 * @exports WoltLabSuite/Core/Language/Input
22 */
23 return {
24 /**
25 * Initializes an input field.
26 *
27 * @param {string} elementId input element id
28 * @param {Object} values preset values per language id
29 * @param {Object} availableLanguages language names per language id
30 * @param {boolean} forceSelection require i18n input
31 */
32 init: function(elementId, values, availableLanguages, forceSelection) {
33 if (_values.has(elementId)) {
34 return;
35 }
36
37 var element = elById(elementId);
38 if (element === null) {
39 throw new Error("Expected a valid element id, cannot find '" + elementId + "'.");
40 }
41
42 this._setup();
43
44 // unescape values
45 var unescapedValues = new Dictionary();
46 for (var key in values) {
47 if (values.hasOwnProperty(key)) {
48 unescapedValues.set(~~key, StringUtil.unescapeHTML(values[key]));
49 }
50 }
51
52 _values.set(elementId, unescapedValues);
53
54 this._initElement(elementId, element, unescapedValues, availableLanguages, forceSelection);
55 },
56
57 /**
58 * Registers a callback for an element.
59 *
60 * @param {string} elementId
61 * @param {string} eventName
62 * @param {function} callback
63 */
64 registerCallback: function (elementId, eventName, callback) {
65 if (!_values.has(elementId)) {
66 throw new Error("Unknown element id '" + elementId + "'.");
67 }
68
69 _elements.get(elementId).callbacks.set(eventName, callback);
70 },
71
72 /**
73 * Caches common event listener callbacks.
74 */
75 _setup: function() {
76 if (_didInit) return;
77 _didInit = true;
78
79 _callbackDropdownToggle = this._dropdownToggle.bind(this);
80 _callbackSubmit = this._submit.bind(this);
81 },
82
83 /**
84 * Sets up DOM and event listeners for an input field.
85 *
86 * @param {string} elementId input element id
87 * @param {Element} element input or textarea element
88 * @param {Dictionary} values preset values per language id
89 * @param {Object} availableLanguages language names per language id
90 * @param {boolean} forceSelection require i18n input
91 */
92 _initElement: function(elementId, element, values, availableLanguages, forceSelection) {
93 var container = element.parentNode;
94 if (!container.classList.contains('inputAddon')) {
95 container = elCreate('div');
96 container.className = 'inputAddon' + (element.nodeName === 'TEXTAREA' ? ' inputAddonTextarea' : '');
97 //noinspection JSCheckFunctionSignatures
98 elData(container, 'input-id', elementId);
99
100 element.parentNode.insertBefore(container, element);
101 container.appendChild(element);
102 }
103
104 container.classList.add('dropdown');
105 var button = elCreate('span');
106 button.className = 'button dropdownToggle inputPrefix';
107
108 var span = elCreate('span');
109 span.textContent = Language.get('wcf.global.button.disabledI18n');
110
111 button.appendChild(span);
112 container.insertBefore(button, element);
113
114 var dropdownMenu = elCreate('ul');
115 dropdownMenu.className = 'dropdownMenu';
116 DomUtil.insertAfter(dropdownMenu, button);
117
118 var callbackClick = (function(event, isInit) {
119 var languageId = ~~elData(event.currentTarget, 'language-id');
120
121 var activeItem = DomTraverse.childByClass(dropdownMenu, 'active');
122 if (activeItem !== null) activeItem.classList.remove('active');
123
124 if (languageId) event.currentTarget.classList.add('active');
125
126 this._select(elementId, languageId, isInit || false);
127 }).bind(this);
128
129 // build language dropdown
130 var listItem;
131 for (var languageId in availableLanguages) {
132 if (availableLanguages.hasOwnProperty(languageId)) {
133 listItem = elCreate('li');
134 elData(listItem, 'language-id', languageId);
135
136 span = elCreate('span');
137 span.textContent = availableLanguages[languageId];
138
139 listItem.appendChild(span);
140 listItem.addEventListener(WCF_CLICK_EVENT, callbackClick);
141 dropdownMenu.appendChild(listItem);
142 }
143 }
144
145 if (forceSelection !== true) {
146 listItem = elCreate('li');
147 listItem.className = 'dropdownDivider';
148 dropdownMenu.appendChild(listItem);
149
150 listItem = elCreate('li');
151 elData(listItem, 'language-id', 0);
152 span = elCreate('span');
153 span.textContent = Language.get('wcf.global.button.disabledI18n');
154 listItem.appendChild(span);
155 listItem.addEventListener(WCF_CLICK_EVENT, callbackClick);
156 dropdownMenu.appendChild(listItem);
157 }
158
159 var activeItem = null;
160 if (forceSelection === true || values.size) {
161 for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
162 //noinspection JSUnresolvedVariable
163 if (~~elData(dropdownMenu.children[i], 'language-id') === LANGUAGE_ID) {
164 activeItem = dropdownMenu.children[i];
165 break;
166 }
167 }
168 }
169
170 UiSimpleDropdown.init(button);
171 UiSimpleDropdown.registerCallback(container.id, _callbackDropdownToggle);
172
173 _elements.set(elementId, {
174 buttonLabel: button.children[0],
175 callbacks: new Dictionary(),
176 element: element,
177 languageId: 0,
178 isEnabled: true,
179 forceSelection: forceSelection
180 });
181
182 // bind to submit event
183 var submit = DomTraverse.parentByTag(element, 'FORM');
184 if (submit !== null) {
185 submit.addEventListener('submit', _callbackSubmit);
186
187 var elementIds = _forms.get(submit);
188 if (elementIds === undefined) {
189 elementIds = [];
190 _forms.set(submit, elementIds);
191 }
192
193 elementIds.push(elementId);
194 }
195
196 if (activeItem !== null) {
197 callbackClick({ currentTarget: activeItem }, true);
198 }
199 },
200
201 /**
202 * Selects a language or non-i18n from the dropdown list.
203 *
204 * @param {string} elementId input element id
205 * @param {int} languageId language id or `0` to disable i18n
206 * @param {boolean} isInit triggers pre-selection on init
207 */
208 _select: function(elementId, languageId, isInit) {
209 var data = _elements.get(elementId);
210
211 var dropdownMenu = UiSimpleDropdown.getDropdownMenu(data.element.closest('.inputAddon').id);
212 var item, label = '';
213 for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
214 item = dropdownMenu.children[i];
215
216 var itemLanguageId = elData(item, 'language-id');
217 if (itemLanguageId.length && languageId === ~~itemLanguageId) {
218 label = item.children[0].textContent;
219 }
220 }
221
222 // save current value
223 if (data.languageId !== languageId) {
224 var values = _values.get(elementId);
225
226 if (data.languageId) {
227 values.set(data.languageId, data.element.value);
228 }
229
230 if (languageId === 0) {
231 _values.set(elementId, new Dictionary());
232 }
233 else if (data.buttonLabel.classList.contains('active') || isInit === true) {
234 data.element.value = (values.has(languageId)) ? values.get(languageId) : '';
235 }
236
237 // update label
238 data.buttonLabel.textContent = label;
239 data.buttonLabel.classList[(languageId ? 'add' : 'remove')]('active');
240
241 data.languageId = languageId;
242 }
243
244 if (!isInit) {
245 data.element.blur();
246 data.element.focus();
247 }
248
249 if (data.callbacks.has('select')) {
250 data.callbacks.get('select')(data.element);
251 }
252 },
253
254 /**
255 * Callback for dropdowns being opened, flags items with a missing value for one or more languages.
256 *
257 * @param {string} containerId dropdown container id
258 * @param {string} action toggle action, can be `open` or `close`
259 */
260 _dropdownToggle: function(containerId, action) {
261 if (action !== 'open') {
262 return;
263 }
264
265 var dropdownMenu = UiSimpleDropdown.getDropdownMenu(containerId);
266 var elementId = elData(elById(containerId), 'input-id');
267 var data = _elements.get(elementId);
268 var values = _values.get(elementId);
269
270 var item, languageId;
271 for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
272 item = dropdownMenu.children[i];
273 languageId = ~~elData(item, 'language-id');
274
275 if (languageId) {
276 var hasMissingValue = false;
277 if (data.languageId) {
278 if (languageId === data.languageId) {
279 hasMissingValue = (data.element.value.trim() === '');
280 }
281 else {
282 hasMissingValue = (!values.get(languageId));
283 }
284 }
285
286 item.classList[(hasMissingValue ? 'add' : 'remove')]('missingValue');
287 }
288 }
289 },
290
291 /**
292 * Inserts hidden fields for i18n input on submit.
293 *
294 * @param {Object} event event object
295 */
296 _submit: function(event) {
297 var elementIds = _forms.get(event.currentTarget);
298
299 var data, elementId, input, values;
300 for (var i = 0, length = elementIds.length; i < length; i++) {
301 elementId = elementIds[i];
302 data = _elements.get(elementId);
303 if (data.isEnabled) {
304 values = _values.get(elementId);
305
306 if (data.callbacks.has('submit')) {
307 data.callbacks.get('submit')(data.element);
308 }
309
310 // update with current value
311 if (data.languageId) {
312 values.set(data.languageId, data.element.value);
313 }
314
315 if (values.size) {
316 values.forEach(function(value, languageId) {
317 input = elCreate('input');
318 input.type = 'hidden';
319 input.name = elementId + '_i18n[' + languageId + ']';
320 input.value = value;
321
322 event.currentTarget.appendChild(input);
323 });
324
325 // remove name attribute to enforce i18n values
326 data.element.removeAttribute('name');
327 }
328 }
329 }
330 },
331
332 /**
333 * Returns the values of an input field.
334 *
335 * @param {string} elementId input element id
336 * @return {Dictionary} values stored for the different languages
337 */
338 getValues: function(elementId) {
339 var element = _elements.get(elementId);
340 if (element === undefined) {
341 throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
342 }
343
344 var values = _values.get(elementId);
345
346 // update with current value
347 values.set(element.languageId, element.element.value);
348
349 return values;
350 },
351
352 /**
353 * Sets the values of an input field.
354 *
355 * @param {string} elementId input element id
356 * @param {Dictionary} values values for the different languages
357 */
358 setValues: function(elementId, values) {
359 var element = _elements.get(elementId);
360 if (element === undefined) {
361 throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
362 }
363
364 if (Core.isPlainObject(values)) {
365 values = Dictionary.fromObject(values);
366 }
367
368 element.element.value = '';
369
370 if (values.has(0)) {
371 element.element.value = values.get(0);
372 values['delete'](0);
373 _values.set(elementId, values);
374 this._select(elementId, 0, true);
375 return;
376 }
377
378 _values.set(elementId, values);
379
380 element.languageId = 0;
381 //noinspection JSUnresolvedVariable
382 this._select(elementId, LANGUAGE_ID, true);
383 },
384
385 /**
386 * Disables the i18n interface for an input field.
387 *
388 * @param {string} elementId input element id
389 */
390 disable: function(elementId) {
391 var element = _elements.get(elementId);
392 if (element === undefined) {
393 throw new Error("Expected a valid element, '" + elementId + "' is not an i18n input field.");
394 }
395
396 if (!element.isEnabled) return;
397
398 element.isEnabled = false;
399
400 // hide language dropdown
401 //noinspection JSCheckFunctionSignatures
402 elHide(element.buttonLabel.parentNode);
403 var dropdownContainer = element.buttonLabel.parentNode.parentNode;
404 dropdownContainer.classList.remove('inputAddon');
405 dropdownContainer.classList.remove('dropdown');
406 },
407
408 /**
409 * Enables the i18n interface for an input field.
410 *
411 * @param {string} elementId input element id
412 */
413 enable: function(elementId) {
414 var element = _elements.get(elementId);
415 if (element === undefined) {
416 throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
417 }
418
419 if (element.isEnabled) return;
420
421 element.isEnabled = true;
422
423 // show language dropdown
424 //noinspection JSCheckFunctionSignatures
425 elShow(element.buttonLabel.parentNode);
426 var dropdownContainer = element.buttonLabel.parentNode.parentNode;
427 dropdownContainer.classList.add('inputAddon');
428 dropdownContainer.classList.add('dropdown');
429 },
430
431 /**
432 * Returns true if i18n input is enabled for an input field.
433 *
434 * @param {string} elementId input element id
435 * @return {boolean}
436 */
437 isEnabled: function(elementId) {
438 var element = _elements.get(elementId);
439 if (element === undefined) {
440 throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
441 }
442
443 return element.isEnabled;
444 },
445
446 /**
447 * Returns true if the value of an i18n input field is valid.
448 *
449 * If the element is disabled, true is returned.
450 *
451 * @param {string} elementId input element id
452 * @param {boolean} permitEmptyValue if true, input may be empty for all languages
453 * @return {boolean} true if input is valid
454 */
455 validate: function(elementId, permitEmptyValue) {
456 var element = _elements.get(elementId);
457 if (element === undefined) {
458 throw new Error("Expected a valid i18n input element, '" + elementId + "' is not i18n input field.");
459 }
460
461 if (!element.isEnabled) return true;
462
463 var values = _values.get(elementId);
464
465 var dropdownMenu = UiSimpleDropdown.getDropdownMenu(element.element.parentNode.id);
466
467 if (element.languageId) {
468 values.set(element.languageId, element.element.value);
469 }
470
471 var item, languageId;
472 var hasEmptyValue = false, hasNonEmptyValue = false;
473 for (var i = 0, length = dropdownMenu.childElementCount; i < length; i++) {
474 item = dropdownMenu.children[i];
475 languageId = ~~elData(item, 'language-id');
476
477 if (languageId) {
478 if (!values.has(languageId) || values.get(languageId).length === 0) {
479 // input has non-empty value for previously checked language
480 if (hasNonEmptyValue) {
481 return false;
482 }
483
484 hasEmptyValue = true;
485 }
486 else {
487 // input has empty value for previously checked language
488 if (hasEmptyValue) {
489 return false;
490 }
491
492 hasNonEmptyValue = true;
493 }
494 }
495 }
496
497 return (!hasEmptyValue || permitEmptyValue);
498 }
499 };
500 });