inline_feedback-1.0.x-dev/assets/js/index.js
assets/js/index.js
(function (Drupal, once) {
// Creates string with the element path
const generateDomPath = (el) => {
const parts = [];
while (el && el.nodeType === Node.ELEMENT_NODE && el !== document.body) {
let part = el.tagName.toLowerCase();
const siblings = Array.from(el.parentNode.children)
.filter((sibling) => sibling.tagName === el.tagName);
if (siblings.length > 1) {
const index = siblings.indexOf(el) + 1;
part += `:nth-of-type(${index})`;
}
parts.unshift(part);
el = el.parentElement;
}
return parts.join(' > ');
}
// Drupal dialog to send feedback information
const openInlineFeedbackModal = (selector, nodeId, settings) => {
const modal = document.createElement('div');
modal.innerHTML = `
<form class="inline-feedback-form">
<div class="form-item">
<label style="display: block;" for="feedback-label">Feedback Label</label>
<input type="text" id="feedback-label" name="label" required>
</div>
<div class="form-item">
<label for="feedback-message">Description</label>
<textarea id="feedback-message" name="description" required></textarea>
</div>
</form>
`;
const dialogInstance = Drupal.dialog(modal, {
title: 'Send feedback',
dialogClass: 'inline-feedback-dialog',
width: 600,
buttons: [
{
text: 'Send',
click: function (e) {
// form data
const form = modal.querySelector('.inline-feedback-form');
// if inputs are empty
if (!form.label.value.trim() || !form.description.value.trim()) {
return;
}
// avoids multiple submits
const submitBtn = e.currentTarget;
submitBtn.disabled = true;
// feedback data
const data = {
label: form.label.value,
description: form.description.value,
selector: selector,
node: nodeId
};
fetch('/inline-feedback/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
}).then((res) => {
if (res.ok) {
Toastify({
text: Drupal.t("✅ Feedback sent"),
duration: 1500,
close: true,
gravity: "bottom",
position: "right",
style: {
background: "#000",
color: "#FFF"
},
stopOnFocus: true,
}).showToast();
} else {
Toastify({
text: Drupal.t("Error sending feedback."),
duration: 1500,
close: true,
gravity: "bottom",
position: "right",
style: {
background: "#922b21",
color: "#FFF"
},
stopOnFocus: true,
}).showToast();
}
setTimeout(() => {
if (res.ok) {
const styles = settings.inline_feedback.styles || {
background: '#2b3c82',
color: '#FFFFFF',
};
const hasDeletePermission = settings.inline_feedback.allowed_to_delete;
displayFeedbacks([data], hasDeletePermission);
}
dialogInstance.close();
}, 2000);
});
}
},
{
text: 'Cancel',
click: function () {
dialogInstance.close();
}
}
]
});
dialogInstance.showModal();
}
// Delete feedback modal
const showConfirm = (feedback, message) => {
return new Promise((resolve) => {
const modal = document.createElement('div');
modal.className = 'inline-feedback-modal';
modal.innerHTML = `
<p class="inline-feedback-modal__title">${message}</p>
<div class="inline-feedback-modal__content">
<button class="inline-feedback-modal__button confirm">Yes</button>
<button class="inline-feedback-modal__button deny">No</button>
</div>
`;
feedback.appendChild(modal);
modal.querySelector('.inline-feedback-modal__button.confirm').addEventListener('click', () => {
resolve(true);
modal.remove();
});
modal.querySelector('.inline-feedback-modal__button.deny').addEventListener('click', () => {
resolve(false);
modal.remove();
});
});
}
// Display each feedback associated with current node
const displayFeedbacks = (feedbacks, hasDeletePermission) => {
feedbacks.forEach((feedback) => {
const element = document.querySelector(decodeURIComponent(feedback.selector));
if (element) {
// IDs
const tooltipId = `marker-tooltip-${feedback.id}`;
const triggerId = `marker-trigger-${feedback.id}`;
const tooltip = document.createElement('div');
tooltip.className = 'inline-feedback-marker';
tooltip.id = `feedback--${feedback.id}`;
tooltip.setAttribute('role', 'dialog');
tooltip.setAttribute('aria-labelledby', triggerId);
tooltip.setAttribute('aria-hidden', 'true');
const trigger = document.createElement('button');
trigger.className = 'inline-feedback-marker__open';
trigger.setAttribute('aria-label', Drupal.t('Open feedback'));
trigger.setAttribute('type', 'button');
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-controls', tooltipId);
trigger.setAttribute('aria-haspopup', 'dialog');
const markerIcon = document.createElement('i');
markerIcon.classList.add('fa-solid', 'fa-thumbtack');
const wrapper = document.createElement('div');
wrapper.className = 'inline-feedback-marker__wrapper';
const title = document.createElement('h3');
title.className = 'inline-feedback-marker__title';
title.textContent = `${feedback.label}`;
const description = document.createElement('p');
description.className = 'inline-feedback-marker__description';
description.innerHTML = `${feedback.description}`;
const btnClose = document.createElement('button');
btnClose.className = 'inline-feedback-marker__close';
btnClose.setAttribute('aria-label', Drupal.t('Close feedback'));
const closeIcon = document.createElement('i');
closeIcon.classList.add('fa-solid', 'fa-xmark');
btnClose.setAttribute('aria-label', 'Close tooltip');
wrapper.appendChild(title);
wrapper.appendChild(description);
btnClose.appendChild(closeIcon);
wrapper.appendChild(btnClose);
trigger.appendChild(markerIcon);
tooltip.appendChild(trigger);
tooltip.appendChild(wrapper);
element.insertAdjacentElement('afterend', tooltip);
// Open tooltip
const openTooltip = () => {
tooltip.classList.add('inline-feedback-marker--open');
trigger.setAttribute('aria-expanded', 'true');
tooltip.setAttribute('aria-hidden', 'false');
// focus first element
const focusable = tooltip.querySelector('button, [href], input, select, textarea, [tabindex="0"]');
if (focusable) focusable.focus();
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keydown', trapFocus);
};
// Close tooltip
const closeTooltip = () => {
tooltip.classList.remove('inline-feedback-marker--open');
trigger.setAttribute('aria-expanded', 'false');
tooltip.setAttribute('aria-hidden', 'true');
trigger.focus();
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('keydown', trapFocus);
};
const trapFocus = (e) => {
if (e.key !== 'Tab') return;
const focusableSelectors = `
button,
[href],
input,
select,
textarea,
[tabindex]:not([tabindex="-1"])
`;
const focusable = Array.from(tooltip.querySelectorAll(focusableSelectors))
.filter(el => !el.disabled && el.offsetParent !== null);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const isShift = e.shiftKey;
const active = document.activeElement;
// SHIFT + TAB
if (isShift) {
if (active === first) {
e.preventDefault();
last.focus();
}
return;
}
// TAB
if (!isShift) {
if (active === last) {
e.preventDefault();
first.focus();
}
}
}
// Toggle click
trigger.addEventListener('click', (e) => {
e.preventDefault();
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
isOpen ? closeTooltip() : openTooltip();
});
// ESC to close
const onKeyDown = (e) => {
if (e.key === 'Escape') {
closeTooltip();
}
};
btnClose.addEventListener('click', closeTooltip);
// display delete button if has permissions
if (hasDeletePermission) {
const btnDelete = document.createElement('button');
btnDelete.className = 'inline-feedback-marker__delete';
btnDelete.setAttribute('aria-label', Drupal.t('Delete inline feedback'));
const deleteIcon = document.createElement('i');
deleteIcon.classList.add('fa-solid', 'fa-trash');
btnDelete.appendChild(deleteIcon);
btnClose.parentNode.insertBefore(btnDelete, btnClose);
btnDelete.addEventListener('click', async (e) => {
e.preventDefault();
// Confirm user action
const confirmed = await showConfirm(tooltip, Drupal.t('Are you sure to delete this feedback?'));
if (!confirmed) return;
try {
let csrfToken = Drupal.csrfToken;
if (!csrfToken) {
const tokenRes = await fetch(Drupal.url('session/token'));
csrfToken = await tokenRes.text();
}
const res = await fetch(Drupal.url(`inline-feedback/delete/${feedback.id}`), {
method: 'DELETE',
headers: {
'X-CSRF-Token': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!res.ok) {
throw new Error('Failed to delete feedback');
}
// removing from DOM
if (tooltip) {
tooltip.remove();
}
// removing from jump list
const item = document.querySelector(`.feedback-jump-list .feedback-jump-list__item[href="#feedback--${feedback.id}]"`);
if (item) {
item.remove();
}
// delete message
Toastify({
text: Drupal.t("✅ Feedback deleted"),
duration: 1500,
close: true,
gravity: "bottom",
position: "right",
style: {
background: "#000",
color: "#FFF"
},
stopOnFocus: true,
}).showToast();
} catch (error) {
console.error("DELETE ERROR:", error);
Toastify({
text: Drupal.t("Error deleting feedback."),
duration: 1500,
close: true,
gravity: "bottom",
position: "right",
style: {
background: "#922b21",
color: "#FFF"
},
stopOnFocus: true,
}).showToast();
}
});
}
}
});
}
// Create jump feedback list
const displayJumpList = (feedbacks) => {
// Jump list container
const container = document.createElement('div');
container.classList.add('feedback-jump-list');
// Toggle button
const toggleButton = document.createElement('button');
toggleButton.classList.add('feedback-jump-list__toggle');
toggleButton.title = Drupal.t('Open feedbacks list.');
toggleButton.setAttribute('aria-haspopup', 'true');
toggleButton.setAttribute('aria-expanded', 'false');
const listIcon = document.createElement('i');
listIcon.classList.add('fa-solid', 'fa-list-ul');
toggleButton.appendChild(listIcon);
// Close button inside modal
const closeButton = document.createElement('button');
closeButton.classList.add('feedback-jump-list__close');
closeButton.setAttribute('aria-label', Drupal.t('Close feedback list'));
const closeIcon = document.createElement('i');
closeIcon.classList.add('fa-solid', 'fa-xmark');
closeButton.appendChild(closeIcon);
// Feedbacks list
const list = document.createElement('div');
list.setAttribute('id', 'feedback-jump-list');
list.classList.add('feedback-jump-list__items', 'hidden');
list.setAttribute('role', 'menu');
toggleButton.setAttribute('aria-controls', 'feedback-jump-list');
list.appendChild(closeButton);
feedbacks.forEach(fb => {
const anchor = document.createElement('a');
anchor.href = `#feedback--${fb.id}`
anchor.textContent = fb.label || Drupal.t('No title.');
anchor.classList.add('feedback-jump-list__item');
anchor.setAttribute('role', 'menuitem');
// Anchors
anchor.addEventListener('click', (e) => {
e.preventDefault();
toggleFeedbackList(false);
const target = document.querySelector(`#feedback--${fb.id}`);
if (target) {
const yOffset = -80;
const y = target.getBoundingClientRect().top + window.scrollY + yOffset;
window.scrollTo({ top: y, behavior: 'smooth' });
}
});
list.appendChild(anchor);
});
// Append jump list to document
container.appendChild(list);
container.appendChild(toggleButton);
document.body.appendChild(container);
const focusableElements = () =>
list.querySelectorAll('button, [href], [tabindex]:not([tabindex="-1"])');
const getFirstFocusable = () => focusableElements()[0];
const getLastFocusable = () => focusableElements()[focusableElements().length - 1];
// Open/close feedback list
const toggleFeedbackList = (forceState = null) => {
const isOpening = (forceState !== null) ? forceState : !toggleButton.classList.contains('open');
list.classList.toggle('hidden', !isOpening);
toggleButton.classList.toggle('open', isOpening);
toggleButton.setAttribute('aria-expanded', isOpening);
if (isOpening) {
listIcon.classList.replace('fa-list-ul', 'fa-xmark');
getFirstFocusable().focus();
document.addEventListener('keydown', trapFocus);
document.addEventListener('keydown', onKeyDown);
} else {
listIcon.classList.replace('fa-xmark', 'fa-list-ul');
toggleButton.focus();
document.removeEventListener('keydown', trapFocus);
document.removeEventListener('keydown', onKeyDown);
}
};
// Trap focus inside feedback list
const trapFocus = (e) => {
if (e.key !== 'Tab') return;
const first = getFirstFocusable();
const last = getLastFocusable();
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
// Accessibility tooling
const onKeyDown = (e) => {
const items = Array.from(focusableElements());
const activeIndex = items.indexOf(document.activeElement);
const activeElement = document.activeElement;
if (items.length === 0 || activeIndex === -1) return;
switch (e.key) {
case 'Escape':
toggleFeedbackList(false);
break;
case 'ArrowDown':
e.preventDefault();
items[(activeIndex + 1) % items.length].focus();
break;
case 'ArrowUp':
e.preventDefault();
items[(activeIndex - 1 + items.length) % items.length].focus();
break;
case 'Enter':
case ' ':
if (activeElement.classList.contains('feedback-jump-list__close')) {
e.preventDefault();
toggleFeedbackList(false);
}
break;
}
};
// Principal Toggle
toggleButton.addEventListener('click', toggleFeedbackList);
}
Drupal.behaviors.inlineFeedbackInit = {
attach(context, settings) {
once('inline-feedback', 'body', context).forEach((body) => {
const styles = settings.inline_feedback.styles || {
background: '#2b3c82',
color: '#FFFFFF',
};
// Assign css variables to :root
document.documentElement.style.setProperty('--inline-feedback-background', styles.background);
document.documentElement.style.setProperty('--inline-feedback-color', styles.color);
// feedbacks creation
body.addEventListener('click', function (e) {
// if user clicks Ctrl
if (!(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
e.stopPropagation();
const clickedElement = e.target;
const selector = generateDomPath(clickedElement);
const nodeId = settings?.node?.nid;
if (!nodeId || !selector) {
Toastify({
text: Drupal.t("Feedback can not be generated."),
duration: 3000,
close: true,
gravity: "bottom",
position: "right",
style: {
background: "#922b21",
color: "#FFF"
},
stopOnFocus: true,
}).showToast();
return;
}
// builds drupal dialog
openInlineFeedbackModal(encodeURIComponent(selector), nodeId, settings);
});
// feedbacks display
const feedbacks = settings.inline_feedback.feedbacks || [];
const hasDeletePermission = settings.inline_feedback.allowed_to_delete;
if (feedbacks.length > 0){
// show feedbacks
displayFeedbacks(feedbacks, hasDeletePermission);
displayJumpList(feedbacks);
}
});
},
};
})(Drupal, once);
