enketo-core
Version:
Extensible Enketo form engine
255 lines (227 loc) • 9.15 kB
JavaScript
import $ from 'jquery';
import { t } from 'enketo/translator';
import Widget from '../../js/widget';
import events from '../../js/event';
/**
* Visually transforms a question into a comment modal that can be shown on its linked question.
*
* @augments Widget
*/
class Comment extends Widget {
/**
* @type {string}
*/
static get selector() {
return '.or-appearance-comment input[type="text"][data-for], .or-appearance-comment textarea[data-for]';
}
/**
* @type {string}
*/
static get helpersRequired() {
return ['input', 'pathToAbsolute'];
}
_init() {
this.linkedQuestion = this._getLinkedQuestion(this.element);
this.commentQuestion = this.question;
if (this.linkedQuestion) {
// Adding role='comment' is for now only used to make sure that role is not 'page' as that messes things up
this.commentQuestion.classList.add('hide');
this.commentQuestion.setAttribute('role', 'comment');
// Any <button> inside a <label> receives click events if the <label> is clicked!
// See http://codepen.io/MartijnR/pen/rWJeOG?editors=1111
const fragment = document
.createRange()
.createContextualFragment(
'<a class="btn-icon-only btn-comment aria-label="comment" type="button" href="#"><i class="icon"> </i></a>'
);
const labels =
this.linkedQuestion.querySelectorAll('.question-label');
labels[labels.length - 1].after(fragment);
this.commentButton =
this.linkedQuestion.querySelector('.btn-comment');
this._setCommentButtonState(this.originalInputValue);
this._setCommentButtonHandler();
this._setValidationHandler();
this._setFocusHandler();
}
}
/**
* @param {Element} input - form control HTML element
* @return {Element} the HTML question the widget is linked with
*/
_getLinkedQuestion(input) {
const contextPath = this.options.helpers.input.getName(input);
const targetPath = this.element.dataset.for.trim();
const absoluteTargetPath = this.options.helpers.pathToAbsolute(
targetPath,
contextPath
);
// The root is nearest repeat or otherwise nearest form. This avoids having to calculate indices, without
// diminishing the flexibility in any meaningful way,
// as it e.g. wouldn't make sense to place a comment node for a top-level question, inside a repeat.
const root = input.closest('form.or, .or-repeat');
return this.options.helpers.input.getWrapNode(
root.querySelector(
`[name="${absoluteTargetPath}"], [data-name="${absoluteTargetPath}"]`
)
);
}
/**
* @return {boolean} whether comment has error
*/
_commentHasError() {
return (
this.commentQuestion.classList.contains('invalid-required') ||
this.commentQuestion.classList.contains('invalid-constraint')
);
}
/**
* @param {*} value - comment value
* @param {Error} error - error instance
*/
_setCommentButtonState(value, error) {
value = typeof value === 'string' ? value.trim() : value;
this.commentButton.classList.toggle('empty', !value);
this.commentButton.classList.toggle('invalid', !!error);
}
/**
* Sets comment button handler
*/
_setCommentButtonHandler() {
this.commentButton.addEventListener('click', (ev) => {
if (this._isCommentModalShown(this.linkedQuestion)) {
this._hideCommentModal(this.linkedQuestion);
} else {
this._showCommentModal();
}
ev.preventDefault();
ev.stopPropagation();
});
}
/**
* Sets validation handler
*/
_setValidationHandler() {
this.element
.closest('form.or')
.addEventListener(events.ValidationComplete().type, () => {
const error = this._commentHasError();
const value = this.originalInputValue;
this._setCommentButtonState(value, error);
});
}
/**
* Sets focus handler
*/
_setFocusHandler() {
$(this.element).on('applyfocus', () => {
if (this.commentButton.matches(':visible')) {
this.commentButton.click();
} else {
console.warn(
`The linked question is not visible. Cannot apply focus to ${this.element.getAttribute(
'name'
)}`
);
}
});
}
/**
* @param {Element} linkedQuestion - the HTML question the widget is linked with
* @return {boolean} whether comment modal is currently shown
*/
_isCommentModalShown(linkedQuestion) {
return !!linkedQuestion.querySelector('.or-comment-widget');
}
/**
* Shows comment modal
*/
_showCommentModal() {
const comment = this.question.cloneNode(true);
const updateText = t('widget.comment.update') || 'Update';
const input = comment.querySelector(
'input:not(.ignore), textarea:not(.ignore)'
);
comment.classList.remove('hide');
input.classList.add('ignore');
input.removeAttribute('name data-for data-type-xml');
const fragment = document.createRange().createContextualFragment(
`<section class="widget or-comment-widget">
<div class="or-comment-widget__content">
<button class="btn btn-primary or-comment-widget__content__btn-update" type="button">${updateText}</button>
<button class="btn-icon-only or-comment-widget__content__btn-close-x" type="button">×</button>
</div>
</section>
`
);
fragment.querySelector('.or-comment-widget__content').prepend(comment);
const overlayFrag = document
.createRange()
.createContextualFragment(
'<div class="or-comment-widget__overlay"></div>'
);
this.linkedQuestion.prepend(fragment);
// .find( '.or-comment-widget' ).remove().end()
this.linkedQuestion.before(overlayFrag);
const overlay = this.linkedQuestion.previousElementSibling;
const widget = this.linkedQuestion.querySelector('.or-comment-widget');
const updateButton = widget.querySelector(
'.or-comment-widget__content__btn-update'
);
const closeButton = widget.querySelector(
'.or-comment-widget__content__btn-close-x'
);
input.focus();
widget.scrollIntoView(false);
updateButton.addEventListener('click', (ev) => {
const { value } = input;
this.originalInputValue = value;
this.element.dispatchEvent(events.Change());
const error = this._commentHasError();
this._setCommentButtonState(value, error);
this._hideCommentModal(this.linkedQuestion);
/*
* Any current error state shown in the linked question will not automatically update.
* It only updates when its **own** value changes.
* See https://github.com/kobotoolbox/enketo-express/issues/608
* Since a linked question and a comment belong so closely together, and likely have
* a `required` or `constraint` dependency, it makes sense to
* separately call a validate method on the linked question to update the error state if necessary.
*
* Note that with setting "validateContinously" set to "true" this means it will be validated twice.
*/
this.options.helpers.input.validate(
$(
this.linkedQuestion.querySelector(
'input:not(.ignore), select:not(.ignore), textarea:not(.ignore)'
)
)
);
ev.preventDefault();
ev.stopPropagation();
});
closeButton.addEventListener('click', (ev) => {
this._hideCommentModal(this.linkedQuestion);
ev.stopPropagation();
ev.preventDefault();
});
overlay.addEventListener('click', (ev) => {
this._hideCommentModal(this.linkedQuestion);
ev.stopPropagation();
ev.preventDefault();
});
}
/**
* Hides comment modal
*
* @param {Element} linkedQuestion - the HTML question the widget is linked with
*/
_hideCommentModal(linkedQuestion) {
linkedQuestion.querySelector('.or-comment-widget').remove();
const overlay = linkedQuestion.previousElementSibling;
if (overlay && overlay.matches('.or-comment-widget__overlay')) {
overlay.remove();
}
}
}
export default Comment;