📦 Complete Example

Sortable Card List with All Features

A full-featured card editor with drag-and-drop sorting, collapsible sections, image picker, and more:

export default {
    name: 'card-list',
    displayName: 'Card List',
    version: '1.4.0',

    settings: {
        columns: {
            type: 'number',
            label: 'Columns',
            default: 3,
            min: 1,
            max: 6
        }
    },

    editor: {
        openContentEditor: function (element, builder, onChange) { // builder is COntentBuilder/ContentBox object
            // Load SortableJS dynamically
            const loadSortable = () => {
                return new Promise((resolve, reject) => {
                    if (window.Sortable) {
                        resolve(window.Sortable);
                        return;
                    }
                    const script = document.createElement('script');
                    script.src = 'https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js';
                    script.onload = () => resolve(window.Sortable);
                    script.onerror = reject;
                    document.head.appendChild(script);
                });
            };

            const container = document.createElement('div');
            const cards = element.querySelectorAll('.card');

            const getCardContainer = (el) =>
                el.matches('.card') ? el : el.querySelector('.card');

            // Collapsible Card Editor
            const createCardEditor = (cardWrapper, index) => {
                const card = getCardContainer(cardWrapper);

                const section = document.createElement('div');
                section.dataset.cardId = cardWrapper.dataset.cardId || `card-${Date.now()}-${index}`;
                cardWrapper.dataset.cardId = section.dataset.cardId;

                // Header
                const header = document.createElement('div');

                const titleText =
                    card.querySelector('h3')?.textContent || `New Card ${index + 1}`;

                const headerTitle = document.createElement('span');
                headerTitle.textContent = titleText;

                const thumb = document.createElement('img');
                thumb.src = card.querySelector('img')?.src || 'https://placehold.co/40x40?text=No+Img';
                thumb.style.cssText = `
                    width: 32px; height: 32px; object-fit: cover;
                    border-radius: 4px; margin-right: 8px;
                `;

                const left = document.createElement('div');
                left.style.display = 'flex';
                left.style.alignItems = 'center';
                
                // Drag handle icon
                const dragHandle = document.createElement('span');
                dragHandle.innerHTML = 'â ¿';
                dragHandle.style.cssText = `
                    margin-right: 8px;
                    font-size: 18px;
                    color: #999;
                    cursor: move;
                `;
                dragHandle.className = 'drag-handle';
                
                left.appendChild(dragHandle);
                left.appendChild(thumb);
                left.appendChild(headerTitle);

                const toggle = document.createElement('span');
                toggle.innerHTML = '<svg style="width: 20px; height: 20px;transform: rotate(360deg);transition: all 0.2s ease"><use xlink:href="#icon-chevron-down"></use></svg>';

                header.appendChild(left);
                header.appendChild(toggle);

                // Body
                const body = document.createElement('div');
                body.style.cssText = `
                    padding: 12px;
                    display: none;
                `;

                // --- Fields ---
                // Image URL + Preview
                const imgPreview = document.createElement('img');
                imgPreview.src = card.querySelector('img')?.src || 'https://placehold.co/80x80?text=No+Img';
                imgPreview.style.cssText = `
                    width: 80px; height: 80px; object-fit: cover;
                    display: block; border-radius: 6px; margin-bottom: 8px;
                `;

                // Delete button
                const deleteBtn = document.createElement('button');
                deleteBtn.innerHTML = '<svg><use xlink:href="#icon-trash2"></use></svg>';
                deleteBtn.style.cssText = 'width:45px';
                deleteBtn.onclick = (e) => {
                    e.preventDefault();
                    const target = cardWrapper.tagName === 'A' ? cardWrapper : card;
                    target.remove();
                    section.remove();
                    onChange?.();
                };
                
                const group1 = document.createElement('div');
                group1.style.cssText = 'display: flex; justify-content: space-between;';
                group1.appendChild(imgPreview);
                group1.appendChild(deleteBtn);

                const imgInput = document.createElement('input');
                imgInput.type = 'text';
                imgInput.style.cssText = 'flex: 1;';
                imgInput.placeholder = 'Image URL';
                imgInput.value = card.querySelector('img')?.src || '';

                imgInput.addEventListener('input', () => {
                    let img = card.querySelector('img');
                    if (!img) {
                        img = document.createElement('img');
                        card.prepend(img);
                    }
                    img.src = imgInput.value;
                    imgPreview.src = imgInput.value || 'https://placehold.co/80x80?text=No+Img';
                    thumb.src = imgPreview.src;
                    onChange?.();
                });

                const browseBtn = document.createElement('button');
                browseBtn.innerHTML = '<svg><use xlink:href="#icon-folder"></use></svg>';
                browseBtn.style.cssText = 'width: 45px;';
                browseBtn.onclick = (e) => {
                    e.preventDefault();
                    builder.openFilePicker('image', (url) => {
                        imgInput.value = url;
                        let img = card.querySelector('img');
                        img.src = url;
                        imgPreview.src = url;
                        thumb.src = url;

                        if (typeof onChange === 'function') {
                            onChange();
                        }
                    }, browseBtn);
                };

                const group2 = document.createElement('div');
                group2.style.cssText = 'display: flex; gap: 4px; margin-bottom: 8px;';
                group2.appendChild(imgInput);
                group2.appendChild(browseBtn);

                // Title
                const titleInput = document.createElement('input');
                titleInput.type = 'text';
                titleInput.placeholder = 'Title';
                titleInput.value = card.querySelector('h3')?.textContent || '';
                titleInput.style.cssText = 'width: 100%; margin-bottom: 8px;';

                titleInput.addEventListener('input', () => {
                    card.querySelector('h3').textContent = titleInput.value;
                    headerTitle.textContent = titleInput.value || 'Untitled Card';
                    onChange?.();
                });

                // Description
                const descInput = document.createElement('textarea');
                descInput.placeholder = 'Description';
                descInput.value = card.querySelector('p')?.textContent || '';
                descInput.rows = 3;
                descInput.style.cssText = 'width: 100%; height: 180px; margin-bottom: 8px;';

                let descTimer;
                descInput.addEventListener('input', () => {
                    clearTimeout(descTimer);
                    descTimer = setTimeout(() => {
                        card.querySelector('p').textContent = descInput.value;
                        onChange?.();
                    }, 400);
                });

                // Link URL
                const linkInput = document.createElement('input');
                linkInput.type = 'text';
                linkInput.placeholder = 'Link URL (optional)';
                linkInput.value =
                    cardWrapper.tagName === 'A' ? cardWrapper.getAttribute('href') || '' : '';
                linkInput.style.cssText = 'width: 100%; margin-bottom: 8px;';

                linkInput.addEventListener('input', () => {
                    const link = linkInput.value.trim();
                    const isWrapped = cardWrapper.tagName === 'A';
                    const isMedia = /\.(jpe?g|png|gif|webp|bmp|svg|mp4|webm|ogg)$/i.test(link);
                    if (link && !isWrapped) {
                        const a = document.createElement('a');
                        a.href = link;
                        a.target = '_blank';
                        a.rel = 'noopener noreferrer';
                        a.dataset.cardId = cardWrapper.dataset.cardId;
                        if (isMedia) a.classList.add('is-lightbox');
                        a.appendChild(cardWrapper.cloneNode(true));
                        cardWrapper.replaceWith(a);
                        onChange?.();
                    } else if (!link && isWrapped) {
                        const a = cardWrapper;
                        const newCard = a.querySelector('.card');
                        newCard.dataset.cardId = a.dataset.cardId;
                        a.replaceWith(newCard);
                        onChange?.();
                    } else if (link && isWrapped) {
                        cardWrapper.setAttribute('href', link);
                        if (isMedia) cardWrapper.classList.add('is-lightbox');
                        else cardWrapper.classList.remove('is-lightbox');
                        onChange?.();
                    }
                });

                // Add after description input
                const altLabel = document.createElement('label');
                altLabel.textContent = 'Image Alt Text';
                altLabel.style.cssText = 'display: block; font-size: 13px; font-weight: 500; margin-bottom: 4px; color: #333;';

                const altHint = document.createElement('small');
                altHint.textContent = 'For accessibility. Defaults to card title.';
                altHint.style.cssText = 'display: block; font-size: 11px; color: #666; margin-top: -6px; margin-bottom: 8px;';

                // Add after description input
                const altInput = document.createElement('input');
                altInput.type = 'text';
                altInput.placeholder = 'Alt text (leave empty for decorative images)';
                altInput.value = card.querySelector('img')?.getAttribute('alt') || titleInput.value;
                altInput.style.cssText = 'width: 100%; margin-bottom: 8px; font-size: 13px;';

                altInput.addEventListener('input', () => {
                    const img = card.querySelector('img');
                    if (img) {
                        img.alt = altInput.value; // Empty string is valid for decorative
                    }
                    onChange?.();
                });

                // When title changes, update alt input IF it matches the old title (keep sync)
                let lastTitle = titleInput.value;
                titleInput.addEventListener('input', () => {
                    card.querySelector('h3').textContent = titleInput.value;
                    headerTitle.textContent = titleInput.value || 'Untitled Card';
                    
                    // Auto-sync alt if it was using the title
                    if (altInput.value === lastTitle) {
                        altInput.value = titleInput.value;
                        const img = card.querySelector('img');
                        if (img) img.alt = titleInput.value;
                    }
                    lastTitle = titleInput.value;
                    
                    onChange?.();
                });

                // Append fields
                body.appendChild(group1);
                body.appendChild(group2);
                body.appendChild(titleInput);
                body.appendChild(descInput);
                body.appendChild(altLabel);
                body.appendChild(altInput);
                body.appendChild(altHint);
                body.appendChild(linkInput);

                // Toggle logic
                header.addEventListener('click', (e) => {
                    if (e.target.closest('.drag-handle')) return;
                    header.style.cursor = 'pointer';
                    const open = body.style.display === 'block';
                    body.style.display = open ? 'none' : 'block';
                    const icon = toggle.querySelector('svg');
                    if(open) {
                        icon.style.transform = 'rotate(360deg)';
                    } else {
                        icon.style.transform = 'rotate(180deg)';
                    }
                });
                
                // Show pointer cursor on hover over header (but not drag handle)
                header.addEventListener('mouseenter', (e) => {
                    if (!e.target.closest('.drag-handle')) {
                        header.style.cursor = 'pointer';
                    }
                });
                
                header.addEventListener('mouseleave', () => {
                    header.style.cursor = '';
                });

                section.appendChild(header);
                section.appendChild(body);

                return section;
            };

            // Sortable container wrapper
            const sortableContainer = document.createElement('div');
            sortableContainer.className = 'sortable-cards-container is-accordion';

            // Existing cards
            cards.forEach((card, i) => {
                const wrapper = card.parentElement.tagName === 'A' ? card.parentElement : card;
                sortableContainer.appendChild(createCardEditor(wrapper, i));
            });

            container.appendChild(sortableContainer);

            // Initialize SortableJS
            loadSortable().then((Sortable) => {
                new Sortable(sortableContainer, {
                    animation: 150,
                    handle: '.drag-handle',
                    ghostClass: 'sortable-ghost',
                    chosenClass: 'sortable-chosen',
                    dragClass: 'sortable-drag',
                    onEnd: function(evt) {
                        // Reorder actual cards in the element
                        const editorCards = Array.from(sortableContainer.children).filter(c => c.dataset.cardId);
                        const elementCards = Array.from(element.children);
                        
                        // Create a map of cardId to actual element
                        const cardMap = new Map();
                        elementCards.forEach(card => {
                            const cardId = card.dataset.cardId;
                            if (cardId) cardMap.set(cardId, card);
                        });

                        // Reorder actual elements based on editor order
                        editorCards.forEach((editorCard, index) => {
                            const cardId = editorCard.dataset.cardId;
                            const actualCard = cardMap.get(cardId);
                            if (actualCard && actualCard.parentElement === element) {
                                element.appendChild(actualCard);
                            }
                        });

                        onChange?.();
                    }
                });

                // Add CSS for sortable states
                const style = document.createElement('style');
                style.textContent = `
                    .sortable-ghost {
                        opacity: 0.4;
                        background: #f0f0f0;
                    }
                    .sortable-chosen {
                        background: #f9f9f9;
                    }
                    .sortable-drag {
                        opacity: 0.8;
                    }
                `;
                container.appendChild(style);
            }).catch((error) => {
                console.error('Failed to load SortableJS:', error);
            });

            // Add Card button
            const addBtn = document.createElement('button');
            addBtn.textContent = '+ Add Card';
            addBtn.onclick = (e) => {
                e.preventDefault();
                const newCard = document.createElement('div');
                newCard.className = 'card';
                newCard.dataset.cardId = `card-${Date.now()}`;
                newCard.innerHTML = `
                    <img src="https://placehold.co/600x600?text=No+Img" alt="">
                    <h3>New Card</h3>
                    <p>Description</p>
                `;
                element.appendChild(newCard);
                const editor = createCardEditor(newCard, sortableContainer.children.length);
                sortableContainer.appendChild(editor);
                onChange?.();
            };

            container.appendChild(addBtn);
            return container; // Must returns element to update content inside the plugin element
        }
    },

    mount: function (element, options) {
        element.style.display = 'grid';
        element.style.gridTemplateColumns = `repeat(${options.columns || 3}, 1fr)`;
        element.style.gap = '16px';
        return {};
    }
};

The Style:

/* Card List Container */
[data-cb-type="card-list"] {
    display: grid;
    gap: 24px;
    padding: 20px 0;
}

/* Individual Card */
[data-cb-type="card-list"] .card {
    position: relative;
    display: flex;
    flex-direction: column;
    background-color: #ffffff;
    border: 1px solid #e5e7eb;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
    transition: box-shadow 0.25s ease, border-color 0.25s ease, transform 0.25s ease;
}

/* Hover Effect */
[data-cb-type="card-list"] .card:hover {
    transform: translateY(-2px);
    border-color: #d1d5db;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}

/* Linked Card (entire card clickable) */
[data-cb-type="card-list"] a {
    display: block;
    border-radius: 12px;
    overflow: hidden;
    text-decoration: none;
    color: inherit;
    transition: transform 0.25s ease;
}
[data-cb-type="card-list"] a:hover {
    transform: translateY(-2px);
}
[data-cb-type="card-list"] a .card {
    cursor: pointer;
}

/* Card Image */
[data-cb-type="card-list"] .card img {
    width: 100%;
    height: 200px;
    object-fit: cover;
    display: block;
    transition: transform 0.3s ease, opacity 0.3s ease;
}
[data-cb-type="card-list"] .card:hover img {
    transform: scale(1.03);
    opacity: 0.96;
}

/* Card Content */
[data-cb-type="card-list"] .card h3,
[data-cb-type="card-list"] .card p {
    padding: 0 20px;
}

[data-cb-type="card-list"] .card h3 {
    margin: 16px 0 8px;
    font-size: 18px;
    font-weight: 500;
    color: #111827;
    line-height: 1.4;
}

[data-cb-type="card-list"] .card p {
    margin: 0 0 20px;
    font-size: 14.5px;
    color: #4b5563;
    line-height: 1.6;
}

/* Responsive */
@media (max-width: 1024px) {
    [data-cb-type="card-list"] .card img {
        height: 180px;
    }
}
@media (max-width: 768px) {
    [data-cb-type="card-list"] {
        grid-template-columns: 1fr !important;
        gap: 16px;
    }
    [data-cb-type="card-list"] .card img {
        height: 160px;
    }
}

HTML Usage:

<div data-cb-type="card-list" data-cb-columns="3">
    <div class="card">
        <img src="image1.jpg" alt="Card 1">
        <h3>Card 1</h3>
        <p>Description 1</p>
    </div>
    <div class="card">
        <img src="image2.jpg" alt="Card 2">
        <h3>Card 2</h3>
        <p>Description 2</p>
    </div>
</div>

View this page on GitHub

© 2026 InnovaStudio

This site is built with Site Builder Kit