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);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc