📦 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>