« Back to the main page

Custom popup & custom action demo

Forum quote workflow

This example shows the popup layer being fully replaced: different structure, different styling, a top-right close control, and a custom Quote action that writes forum-style BBCode into a reply editor. The core still handles selection geometry, merging, popup placement, and clipboard copy.

Custom popup renderer Shadow DOM styling Forum quote action Code preview

Forum thread

Select text inside a post, then use Quote or Copy from the custom popup.

Popup fully customized

Response

Preview renders valid quotes visually. Code mode exposes the raw forum markup.

Preview / code editor

If the quote markup stays valid, the posted reply renders it as a forum quote. Broken quote tags are shown as raw text, just like a forum post with invalid BBCode.

Code used for this demo

The thread posts use the same shared core, but replace the default popup with a custom renderer. Quote inserts the selected text into the reply composer as forum BBCode, while Copy uses the built-in clipboard action.

<article class="forum-post" data-author="Lyra Vale">
  <p class="forum-post__body">Select any part of this post.</p>
</article>
<textarea id="forum-response" aria-label="Forum response"></textarea>

<script src="/cleanselection.js"></script>
<script>
const postBody = document.querySelector('.forum-post__body');
const responseEditor = document.querySelector('#forum-response');
const author = postBody.closest('[data-author]').dataset.author;

function appendQuote(authorName, selectedText) {
  const quote = `[quote author="${authorName}"]${selectedText.trim()}[/quote]`;
  const separator = responseEditor.value.trim() ? '\n\n' : '';
  responseEditor.value += `${separator}${quote}\n`;
  responseEditor.dispatchEvent(new Event('input', { bubbles: true }));
  responseEditor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

function createForumPopupRenderer(authorName) {
  return {
    create(context) {
      let currentContext = context;
      const { host, shadowRoot } = CleanSelection.createPopupShell('forum-popup-host');

      const style = document.createElement('style');
      style.textContent = `
        .forum-popup {
          min-width: 244px;
          max-width: calc(100vw - 24px);
          border: 1px solid #2b394a;
          background: linear-gradient(180deg, #fff8ee, #f0e1c7);
          color: #243244;
          box-shadow: 8px 8px 0 rgba(43, 57, 74, 0.2);
        }

        .forum-popup__frame {
          display: grid;
          gap: 14px;
          padding: 14px;
        }

        .forum-popup__topline {
          display: flex;
          align-items: center;
          justify-content: space-between;
          gap: 12px;
        }

        .forum-popup__label {
          color: #754126;
          font-size: 0.76rem;
          font-weight: 800;
          letter-spacing: 0.12em;
          text-transform: uppercase;
        }

        .forum-popup__close {
          appearance: none;
          border: 0;
          display: grid;
          place-items: center;
          width: 1.4rem;
          height: 1.4rem;
          padding: 0;
          background: transparent;
          color: #435164;
          cursor: pointer;
        }

        .forum-popup__close svg {
          width: 0.9rem;
          height: 0.9rem;
          stroke: currentColor;
          stroke-width: 2.4;
          stroke-linecap: round;
        }

        .forum-popup__actions {
          display: grid;
          grid-template-columns: repeat(2, minmax(0, 1fr));
          gap: 10px;
        }

        .forum-popup__button {
          appearance: none;
          border: 1px solid #2b394a;
          padding: 0.7rem 0.85rem;
          background: #fffaf1;
          color: #243244;
          font: inherit;
          font-weight: 800;
          cursor: pointer;
        }

        .forum-popup__button--quote {
          border-color: #7a351a;
          background: linear-gradient(180deg, #cb6a3f, #a64d28);
          color: #fff7ef;
        }

        .forum-popup__button--copy[data-state="copied"] {
          background: #2b394a;
          color: #fff8ef;
        }
      `;

      const popup = document.createElement('div');
      popup.className = 'forum-popup';
      popup.innerHTML = `
        <div class="forum-popup__frame">
          <div class="forum-popup__topline">
            <span class="forum-popup__label">Forum actions</span>
            <button type="button" class="forum-popup__close" aria-label="Close">
              <svg viewBox="0 0 16 16" aria-hidden="true">
                <path d="M3 3L13 13"></path>
                <path d="M13 3L3 13"></path>
              </svg>
            </button>
          </div>
          <div class="forum-popup__actions">
            <button type="button" class="forum-popup__button forum-popup__button--quote">Quote</button>
            <button type="button" class="forum-popup__button forum-popup__button--copy">Copy</button>
          </div>
        </div>
      `;

      const closeButton = popup.querySelector('.forum-popup__close');
      const quoteButton = popup.querySelector('.forum-popup__button--quote');
      const copyButton = popup.querySelector('.forum-popup__button--copy');

      closeButton.addEventListener('click', () => currentContext.close());
      copyButton.addEventListener('click', () => currentContext.copy());
      quoteButton.addEventListener('click', () => {
        appendQuote(authorName, currentContext.text);
        currentContext.close();
      });

      shadowRoot.append(style, popup);

      return {
        element: host,
        update(nextContext) {
          currentContext = nextContext;
          copyButton.textContent = nextContext.copyLabel;
          copyButton.dataset.state = nextContext.copied ? 'copied' : 'ready';
        }
      };
    }
  };
}

CleanSelection.attach(postBody, {
  includeCompleteWords: true,
  mergeSelections: true,
  allowErase: true,
  showTextPreview: false,
  externalVirtualPadding: 17,
  popupOffset: 22,
  popupRenderer: createForumPopupRenderer(author)
});
</script>