واجهة برمجة السحب والإفلات
مقدمة في واجهة برمجة السحب والإفلات في HTML5
تتيح واجهة برمجة السحب والإفلات في HTML5 للمستخدمين الإمساك بعنصر في صفحة الويب وسحبه إلى موقع مختلف وإفلاته هناك. يُستخدم نمط التفاعل هذا في مديري الملفات ولوحات كانبان وأدوات تحميل الصور والقوائم القابلة للفرز والعديد من واجهات المستخدم الأخرى. قبل HTML5، كان تنفيذ السحب والإفلات يتطلب تتبعًا معقدًا لأحداث الفأرة وحسابات يدوية للإحداثيات. توفر واجهة برمجة السحب والإفلات الأصلية نهجًا موحدًا يعتمد على الأحداث ويعمل عبر جميع المتصفحات الحديثة دون أي مكتبات خارجية.
بُنيت واجهة برمجة السحب والإفلات حول سلسلة من الأحداث التي تنطلق خلال مراحل مختلفة من عملية السحب. فهم هذه الأحداث وكائن DataTransfer الذي ينقل البيانات بينها هو المفتاح لإتقان هذه الواجهة. في هذا الدرس، ستتعلم كل جانب من جوانب الواجهة: من جعل العناصر قابلة للسحب، إلى التعامل مع جميع أحداث السحب السبعة، إلى بناء ميزات واقعية كاملة مثل القوائم القابلة للفرز ولوحات كانبان ومناطق إفلات الملفات.
تتكون الواجهة من ثلاثة مفاهيم أساسية. أولاً، سمة draggable تجعل عنصر HTML قابلاً للسحب. ثانيًا، أحداث السحب تنطلق في مراحل مختلفة من دورة حياة السحب والإفلات. ثالثًا، كائن DataTransfer ينقل البيانات والإعدادات بين مصدر السحب وهدف الإفلات. معًا، تمكّن هذه القطع تجارب سحب وإفلات تفاعلية غنية بالكامل في المتصفح.
جعل العناصر قابلة للسحب
افتراضيًا، فقط الصور والنص المحدد والروابط قابلة للسحب في HTML. لجعل أي عنصر آخر قابلاً للسحب، يجب إضافة سمة draggable="true" إليه. هذا يخبر المتصفح أنه يمكن التقاط العنصر وسحبه من قبل المستخدم. بدون هذه السمة، لن يكون لمحاولات السحب على العناصر العادية مثل div و p أي تأثير.
مثال: جعل العناصر قابلة للسحب
<!-- هذا العنصر قابل للسحب -->
<div draggable="true" id="drag-item">
اسحبني حول الصفحة!
</div>
<!-- الصور قابلة للسحب افتراضيًا -->
<img src="photo.jpg" alt="صورة" />
<!-- الروابط قابلة للسحب افتراضيًا -->
<a href="https://example.com">اسحب هذا الرابط</a>
<!-- لمنع السحب الافتراضي على الصور -->
<img src="logo.png" alt="شعار" draggable="false" />
draggable ليست سمة منطقية مثل hidden أو disabled. يجب تعيينها صراحة إلى "true" أو "false". كتابة draggable فقط بدون قيمة قد لا تعمل بشكل متسق عبر جميع المتصفحات.عندما يصبح العنصر قابلاً للسحب، يوفر المتصفح صورة شبحية مرئية للعنصر تتبع المؤشر أثناء السحب. يبقى العنصر الأصلي في مكانه ويصبح عادة شبه شفاف. يمكنك تخصيص هذه الصورة الشبحية باستخدام واجهة DataTransfer، والتي سنغطيها لاحقًا في هذا الدرس.
أحداث السحب السبعة
تحدد واجهة برمجة السحب والإفلات سبعة أحداث مميزة تنطلق أثناء عملية السحب. تنقسم هذه الأحداث بين مصدر السحب (العنصر الذي يتم سحبه) وهدف الإفلات (العنصر الذي يمكن إفلات العنصر المسحوب عليه). فهم متى ينطلق كل حدث وعلى أي عنصر ينطلق أمر بالغ الأهمية.
الأحداث على مصدر السحب
تنطلق ثلاثة أحداث على العنصر الذي يتم سحبه:
- dragstart -- ينطلق عندما يبدأ المستخدم بسحب العنصر. هنا تقوم بتعيين البيانات للنقل وتهيئة عملية السحب.
- drag -- ينطلق باستمرار أثناء سحب العنصر. ينطلق هذا الحدث كل بضع مئات من الميلي ثانية وهو مفيد لتحديث الواجهة أثناء السحب.
- dragend -- ينطلق عند انتهاء عملية السحب، سواء تم إفلات العنصر على هدف صالح أم لا. هنا تقوم بتنظيف أي تغييرات مرئية أجريت أثناء السحب.
الأحداث على هدف الإفلات
تنطلق أربعة أحداث على العناصر التي يمكنها استقبال العناصر المُفلتة:
- dragenter -- ينطلق عندما يدخل عنصر مسحوب حدود هدف إفلات محتمل. استخدمه لإضافة تعليقات مرئية تُظهر للمستخدم أنه يمكنه الإفلات هنا.
- dragover -- ينطلق باستمرار أثناء وجود عنصر مسحوب فوق هدف إفلات. يجب استدعاء
event.preventDefault()في هذا المعالج للسماح بالإفلات -- افتراضيًا، لا تقبل العناصر الإفلات. - dragleave -- ينطلق عندما يغادر عنصر مسحوب حدود هدف إفلات. استخدمه لإزالة التعليقات المرئية المضافة في dragenter.
- drop -- ينطلق عندما يحرر المستخدم العنصر المسحوب فوق هدف إفلات صالح. هنا تتعامل مع منطق الإفلات الفعلي -- قراءة البيانات المنقولة وتحديث DOM.
مثال: جميع أحداث السحب السبعة في العمل
<div id="drag-source" draggable="true">اسحبني</div>
<div id="drop-zone">أفلت هنا</div>
<script>
const source = document.getElementById('drag-source');
const zone = document.getElementById('drop-zone');
// الأحداث على مصدر السحب
source.addEventListener('dragstart', (e) => {
console.log('dragstart: بدأ السحب');
e.dataTransfer.setData('text/plain', source.id);
source.style.opacity = '0.5';
});
source.addEventListener('drag', (e) => {
// ينطلق باستمرار أثناء السحب
// استخدمه بحذر لتجنب مشاكل الأداء
});
source.addEventListener('dragend', (e) => {
console.log('dragend: انتهى السحب');
source.style.opacity = '1';
});
// الأحداث على هدف الإفلات
zone.addEventListener('dragenter', (e) => {
e.preventDefault();
console.log('dragenter: دخل العنصر منطقة الإفلات');
zone.classList.add('highlight');
});
zone.addEventListener('dragover', (e) => {
e.preventDefault(); // مطلوب للسماح بالإفلات
console.log('dragover: العنصر فوق منطقة الإفلات');
});
zone.addEventListener('dragleave', (e) => {
console.log('dragleave: غادر العنصر منطقة الإفلات');
zone.classList.remove('highlight');
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
console.log('drop: تم إفلات العنصر');
const draggedId = e.dataTransfer.getData('text/plain');
const draggedEl = document.getElementById(draggedId);
zone.appendChild(draggedEl);
zone.classList.remove('highlight');
});
</script>
event.preventDefault() في معالج حدث dragover. بدون هذا الاستدعاء، لن يسمح المتصفح بحدوث الإفلات ولن ينطلق حدث drop أبدًا. هذا هو الخطأ الأكثر شيوعًا عند تنفيذ السحب والإفلات.كائن DataTransfer
كائن DataTransfer هو الآلية المركزية لتمرير البيانات بين مصدر السحب وهدف الإفلات. وهو متاح على كائن الحدث كـ event.dataTransfer أثناء أحداث السحب. يوفر كائن DataTransfer طرقًا لتعيين واسترجاع البيانات، وتحديد تأثيرات السحب المسموح بها، وتخصيص صورة السحب، والوصول إلى الملفات المسحوبة.
setData() و getData()
تخزن طريقة setData() البيانات في كائن DataTransfer أثناء حدث dragstart. تسترجع طريقة getData() تلك البيانات أثناء حدث drop. كلتا الطريقتين تأخذان سلسلة تنسيق (نوع MIME) كوسيط أول. يمكنك تخزين عدة قطع من البيانات بمفاتيح تنسيق مختلفة.
مثال: استخدام setData و getData
// في معالج dragstart -- تعيين البيانات
element.addEventListener('dragstart', (e) => {
// تخزين نص عادي
e.dataTransfer.setData('text/plain', 'مرحبا بالعالم');
// تخزين محتوى HTML
e.dataTransfer.setData('text/html', '<strong>مرحبا</strong>');
// تخزين عنوان URL
e.dataTransfer.setData('text/uri-list', 'https://example.com');
// تخزين بيانات JSON مخصصة
e.dataTransfer.setData('application/json', JSON.stringify({
id: 42,
type: 'task',
title: 'إكمال الدرس'
}));
});
// في معالج drop -- الحصول على البيانات
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
// استرجاع النص العادي
const text = e.dataTransfer.getData('text/plain');
// استرجاع JSON المخصص
const jsonStr = e.dataTransfer.getData('application/json');
const data = JSON.parse(jsonStr);
console.log(data.title); // "إكمال الدرس"
});
خاصية types
تُرجع خاصية types قائمة DOMStringList بجميع تنسيقات البيانات التي تم تعيينها أثناء dragstart. هذا مفيد في معالجات dragover أو drop للتحقق من نوع البيانات التي يتم سحبها قبل تحديد ما إذا كان يجب قبول الإفلات.
مثال: التحقق من أنواع DataTransfer
dropZone.addEventListener('dragover', (e) => {
// السماح بالإفلات فقط إذا كانت البيانات المسحوبة تحتوي على نوعنا المخصص
if (e.dataTransfer.types.includes('application/json')) {
e.preventDefault(); // السماح بالإفلات
}
// إذا لم تتضمن البيانات نوعنا، فلا يُسمح بالإفلات
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
// سرد جميع الأنواع المتاحة
console.log('الأنواع المتاحة:', [...e.dataTransfer.types]);
// التحقق من نوع محدد قبل القراءة
if (e.dataTransfer.types.includes('text/html')) {
const html = e.dataTransfer.getData('text/html');
console.log('تم استلام HTML:', html);
}
});
خاصية files
عندما يسحب المستخدم ملفات من سطح المكتب أو مدير الملفات إلى المتصفح، تحتوي خاصية files في كائن DataTransfer على قائمة FileList بالملفات المسحوبة. هذا هو الأساس لبناء مناطق إفلات رفع الملفات.
مثال: الوصول إلى الملفات المسحوبة
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
const files = e.dataTransfer.files;
console.log('عدد الملفات:', files.length);
for (const file of files) {
console.log('الاسم:', file.name);
console.log('النوع:', file.type);
console.log('الحجم:', file.size, 'بايت');
console.log('آخر تعديل:', new Date(file.lastModified));
}
});
effectAllowed و dropEffect
يوفر كائن DataTransfer خاصيتين تتحكمان في التعليقات المرئية ونوع عملية السحب: effectAllowed و dropEffect. يتم تعيين خاصية effectAllowed في معالج dragstart وتحدد أنواع العمليات المسموح بها. يتم تعيين خاصية dropEffect في معالج dragover وتحدد العملية التي ستحدث فعليًا إذا تم إفلات العنصر.
مثال: تعيين effectAllowed و dropEffect
// قيم effectAllowed:
// "none" -- لا يُسمح بأي عملية
// "copy" -- نسخ فقط
// "move" -- نقل فقط
// "link" -- ربط فقط
// "copyMove" -- نسخ أو نقل
// "copyLink" -- نسخ أو ربط
// "linkMove" -- ربط أو نقل
// "all" -- نسخ أو نقل أو ربط (افتراضي)
source.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', source.id);
e.dataTransfer.effectAllowed = 'move'; // مسموح بالنقل فقط
});
// قيم dropEffect: "none"، "copy"، "move"، "link"
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move'; // يظهر المؤشر أيقونة النقل
});
// تأثير إفلات مشروط بناءً على مفاتيح التعديل
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
if (e.ctrlKey) {
e.dataTransfer.dropEffect = 'copy'; // Ctrl + سحب = نسخ
} else {
e.dataTransfer.dropEffect = 'move'; // افتراضي = نقل
}
});
effectAllowed إلى "move"، سيُظهر المتصفح مؤشر نقل أثناء السحب. عندما يتم تعيينه إلى "copy"، يُظهر مؤشر نسخ (عادة علامة زائد). هذه التعليقات المرئية تساعد المستخدمين على فهم ما سيحدث عند إفلات العنصر.صور السحب المخصصة
افتراضيًا، ينشئ المتصفح لقطة شبه شفافة للعنصر المسحوب كصورة سحب. يمكنك تخصيص هذا باستخدام طريقة setDragImage() على كائن DataTransfer. تأخذ هذه الطريقة ثلاث وسائط: عنصر صورة (أو أي عنصر)، وإزاحات x و y التي تحدد موضع المؤشر بالنسبة للصورة.
مثال: صور السحب المخصصة
// استخدام صورة مخصصة كصورة السحب
source.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', source.id);
// إنشاء عنصر صورة لصورة السحب
const dragImage = new Image();
dragImage.src = '/icons/drag-icon.png';
// تعيين صورة السحب مع المؤشر في المنتصف (25، 25)
e.dataTransfer.setDragImage(dragImage, 25, 25);
});
// استخدام عنصر DOM مخفي كصورة السحب
source.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', source.id);
// إنشاء عنصر مخصص لمعاينة السحب
const preview = document.createElement('div');
preview.textContent = 'جاري نقل العنصر...';
preview.style.cssText = `
position: absolute;
top: -1000px;
padding: 8px 16px;
background: #3498db;
color: white;
border-radius: 4px;
font-size: 14px;
`;
document.body.appendChild(preview);
// استخدام العنصر المخصص كصورة سحب
e.dataTransfer.setDragImage(preview, 0, 0);
// تنظيف العنصر بعد تأخير قصير
requestAnimationFrame(() => {
document.body.removeChild(preview);
});
});
بناء مناطق الإفلات
منطقة الإفلات هي أي عنصر يقبل العناصر المسحوبة. لإنشاء منطقة إفلات، يجب التعامل مع حدثي dragover و drop على الأقل، مع استدعاء preventDefault() على كليهما. إضافة تعليقات مرئية من خلال حدثي dragenter و dragleave يحسن تجربة المستخدم بشكل كبير من خلال توضيح أين يمكن إفلات العناصر.
مثال: منطقة إفلات كاملة مع تعليقات مرئية
<style>
.drop-zone {
width: 300px;
height: 200px;
border: 2px dashed #ccc;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
color: #999;
font-size: 16px;
}
.drop-zone.active {
border-color: #3498db;
background-color: rgba(52, 152, 219, 0.05);
color: #3498db;
}
.drop-zone.hover {
border-color: #2ecc71;
background-color: rgba(46, 204, 113, 0.1);
transform: scale(1.02);
}
</style>
<div class="drop-zone" id="dropZone">
أفلت العناصر هنا
</div>
<script>
const dropZone = document.getElementById('dropZone');
let dragCounter = 0; // تتبع dragenter/dragleave المتداخلة
dropZone.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
dropZone.classList.add('hover');
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
dropZone.addEventListener('dragleave', (e) => {
dragCounter--;
if (dragCounter === 0) {
dropZone.classList.remove('hover');
}
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
dropZone.classList.remove('hover');
const data = e.dataTransfer.getData('text/plain');
// التعامل مع البيانات المُفلتة
console.log('تم الإفلات:', data);
});
</script>
dragCounter يحل مشكلة شائعة: عند السحب فوق منطقة إفلات تحتوي على عناصر فرعية، تنطلق أحداث dragenter و dragleave لكل عنصر فرعي. بدون تتبع العداد، تومض التعليقات المرئية أثناء التنقل بين العناصر الفرعية. زيادة العداد عند dragenter وإنقاصه عند dragleave يضمن أن التمييز يختفي فقط عندما يغادر المؤشر منطقة الإفلات فعليًا.إعادة ترتيب القوائم بالسحب والإفلات
أحد أكثر حالات الاستخدام شيوعًا للسحب والإفلات هو إعادة ترتيب العناصر في قائمة. يسحب المستخدم عنصرًا من موضع ويفلته في موضع آخر ضمن نفس القائمة. يتطلب هذا تتبع العنصر الذي يتم سحبه، وتحديد موضع الإفلات بالنسبة للعناصر الأخرى، وإعادة ترتيب DOM وفقًا لذلك.
مثال: قائمة قابلة للفرز
<ul id="sortable-list">
<li draggable="true">العنصر 1</li>
<li draggable="true">العنصر 2</li>
<li draggable="true">العنصر 3</li>
<li draggable="true">العنصر 4</li>
<li draggable="true">العنصر 5</li>
</ul>
<script>
const list = document.getElementById('sortable-list');
let draggedItem = null;
list.addEventListener('dragstart', (e) => {
draggedItem = e.target;
e.target.style.opacity = '0.4';
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', e.target.innerHTML);
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('li');
if (!target || target === draggedItem) return;
// تحديد ما إذا كان يجب الإدراج قبل أو بعد
const rect = target.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2;
if (e.clientY < midpoint) {
// الفأرة في النصف العلوي -- إدراج قبل
list.insertBefore(draggedItem, target);
} else {
// الفأرة في النصف السفلي -- إدراج بعد
list.insertBefore(draggedItem, target.nextSibling);
}
});
list.addEventListener('dragend', (e) => {
e.target.style.opacity = '1';
draggedItem = null;
});
</script>
التقنية الأساسية هنا هي حساب نقطة المنتصف لكل عنصر في القائمة أثناء dragover. بمقارنة موضع مؤشر الفأرة (e.clientY) بنقطة المنتصف هذه، نحدد ما إذا كان العنصر المسحوب يجب وضعه قبل أو بعد العنصر المستهدف. هذا ينشئ تجربة إعادة ترتيب سلسة وبديهية حيث تتبع نقطة الإدراج المؤشر بشكل طبيعي.
السحب بين حاويات متعددة
تتطلب العديد من التطبيقات سحب العناصر بين حاويات مختلفة -- على سبيل المثال، نقل المهام بين الأعمدة في أداة إدارة المشاريع، أو تنظيم الملفات في مجلدات. يوسع هذا النمط نهج منطقة الإفلات الأساسي من خلال وجود حاويات متعددة تقبل كل منها الإفلات وتحديد الحاوية التي أتى منها العنصر وأين يذهب.
مثال: السحب بين الحاويات
<div class="container" id="container-a">
<h3>الحاوية أ</h3>
<div class="item" draggable="true" data-id="1">العنصر 1</div>
<div class="item" draggable="true" data-id="2">العنصر 2</div>
</div>
<div class="container" id="container-b">
<h3>الحاوية ب</h3>
<div class="item" draggable="true" data-id="3">العنصر 3</div>
</div>
<script>
const containers = document.querySelectorAll('.container');
let draggedItem = null;
// ربط أحداث السحب بجميع العناصر
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('dragstart', (e) => {
draggedItem = e.target;
e.dataTransfer.setData('text/plain', e.target.dataset.id);
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => {
e.target.classList.add('dragging');
}, 0);
});
item.addEventListener('dragend', (e) => {
e.target.classList.remove('dragging');
draggedItem = null;
});
});
// ربط أحداث منطقة الإفلات بجميع الحاويات
containers.forEach(container => {
container.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const afterElement = getDragAfterElement(container, e.clientY);
if (afterElement) {
container.insertBefore(draggedItem, afterElement);
} else {
container.appendChild(draggedItem);
}
});
container.addEventListener('drop', (e) => {
e.preventDefault();
container.classList.remove('drag-over');
});
});
function getDragAfterElement(container, y) {
const items = [...container.querySelectorAll('.item:not(.dragging)')];
return items.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
</script>
getDragAfterElement هي أداة قوية تجد أقرب عنصر أسفل موضع المؤشر. بمقارنة إحداثي Y للفأرة مع نقطة المنتصف العمودية لكل عنصر فرعي، تحدد بالضبط أين يجب إدراج العنصر المسحوب. تعمل نفس الدالة لكل من إعادة الترتيب في حاوية واحدة والنقل بين حاويات متعددة.مناطق إفلات الملفات
أحد أكثر الاستخدامات العملية لواجهة السحب والإفلات هو إنشاء مناطق إفلات لرفع الملفات. يمكن للمستخدمين سحب الملفات مباشرة من سطح المكتب أو مستكشف الملفات إلى المتصفح، مما يوفر تجربة أكثر بديهية من النقر على زر إدخال ملف تقليدي. يمكن الوصول إلى الملفات من خلال خاصية dataTransfer.files في حدث drop.
مثال: منطقة إفلات ملفات كاملة
<div id="file-drop-zone" class="file-drop">
<p>اسحب وأفلت الملفات هنا</p>
<p>أو</p>
<input type="file" id="file-input" multiple />
</div>
<div id="file-list"></div>
<script>
const fileDropZone = document.getElementById('file-drop-zone');
const fileInput = document.getElementById('file-input');
const fileList = document.getElementById('file-list');
// منع سلوك السحب الافتراضي على المستند بأكمله
// لمنع المتصفح من فتح الملفات المُفلتة
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', (e) => e.preventDefault());
fileDropZone.addEventListener('dragenter', (e) => {
e.preventDefault();
fileDropZone.classList.add('active');
});
fileDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
fileDropZone.addEventListener('dragleave', (e) => {
if (!fileDropZone.contains(e.relatedTarget)) {
fileDropZone.classList.remove('active');
}
});
fileDropZone.addEventListener('drop', (e) => {
e.preventDefault();
fileDropZone.classList.remove('active');
const files = e.dataTransfer.files;
handleFiles(files);
});
// التعامل أيضًا مع إدخال الملف كبديل
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
function handleFiles(files) {
for (const file of files) {
const item = document.createElement('div');
item.className = 'file-item';
const sizeKB = (file.size / 1024).toFixed(1);
item.innerHTML = `
<strong>${file.name}</strong>
<span>${file.type || 'نوع غير معروف'}</span>
<span>${sizeKB} كيلوبايت</span>
`;
// معاينة الصور
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const img = document.createElement('img');
img.src = e.target.result;
img.style.maxWidth = '100px';
item.appendChild(img);
};
reader.readAsDataURL(file);
}
fileList.appendChild(item);
}
}
</script>
dragover و drop إلى document تستدعي preventDefault(). بدون هذه، إذا أفلت المستخدم ملفًا بالخطأ خارج منطقة الإفلات، سينتقل المتصفح بعيدًا عن صفحتك لعرض الملف. هذه مشكلة شائعة جدًا تُحبط المستخدمين.اعتبارات إمكانية الوصول
تعتمد واجهة السحب والإفلات الأصلية بالكامل على تفاعل الفأرة، مما يخلق عوائق كبيرة في إمكانية الوصول. لا يمكن للمستخدمين الذين يتنقلون باستخدام لوحات المفاتيح أو قارئات الشاشة أو أجهزة الإدخال البديلة استخدام أحداث السحب الأصلية. من الضروري توفير طرق تفاعل بديلة جنبًا إلى جنب مع السحب والإفلات.
فيما يلي ممارسات إمكانية الوصول الرئيسية التي يجب اتباعها:
- وفّر بدائل يمكن الوصول إليها بلوحة المفاتيح مثل أزرار لنقل العناصر لأعلى أو لأسفل أو بين الحاويات.
- استخدم سمات ARIA مثل
aria-grabbedوaria-dropeffectوrole="listbox"لتوصيل حالة السحب للتقنيات المساعدة. - أضف تعليمات مرئية تشرح كيفية استخدام كل من طريقتي السحب والإفلات ولوحة المفاتيح.
- استخدم مناطق ARIA الحية للإعلان عن التغييرات عند نقل العناصر.
- تأكد من أن جميع العناصر التفاعلية قابلة للتركيز وقابلة للتشغيل بلوحة المفاتيح.
مثال: سحب وإفلات مع دعم لوحة المفاتيح
<ul id="accessible-list" role="listbox" aria-label="قائمة قابلة لإعادة الترتيب">
<li role="option" tabindex="0" draggable="true" aria-grabbed="false">
<span>العنصر 1</span>
<button class="move-up" aria-label="نقل العنصر 1 لأعلى">أعلى</button>
<button class="move-down" aria-label="نقل العنصر 1 لأسفل">أسفل</button>
</li>
<li role="option" tabindex="0" draggable="true" aria-grabbed="false">
<span>العنصر 2</span>
<button class="move-up" aria-label="نقل العنصر 2 لأعلى">أعلى</button>
<button class="move-down" aria-label="نقل العنصر 2 لأسفل">أسفل</button>
</li>
</ul>
<div aria-live="polite" id="announcer" class="sr-only"></div>
<script>
const announcer = document.getElementById('announcer');
// دعم لوحة المفاتيح: مسافة للإمساك، أسهم للنقل، إدخال للإفلات
document.querySelectorAll('[role="option"]').forEach(item => {
item.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
const grabbed = item.getAttribute('aria-grabbed') === 'true';
item.setAttribute('aria-grabbed', !grabbed);
announcer.textContent = grabbed
? `تم إفلات ${item.textContent.trim()}`
: `تم إمساك ${item.textContent.trim()}. استخدم مفاتيح الأسهم للنقل.`;
}
if (item.getAttribute('aria-grabbed') === 'true') {
if (e.key === 'ArrowUp' && item.previousElementSibling) {
e.preventDefault();
item.parentNode.insertBefore(item, item.previousElementSibling);
announcer.textContent = `تم النقل لأعلى. الآن في الموضع ${getPosition(item)}.`;
item.focus();
}
if (e.key === 'ArrowDown' && item.nextElementSibling) {
e.preventDefault();
item.parentNode.insertBefore(item.nextElementSibling, item);
announcer.textContent = `تم النقل لأسفل. الآن في الموضع ${getPosition(item)}.`;
item.focus();
}
}
});
});
function getPosition(el) {
return [...el.parentNode.children].indexOf(el) + 1;
}
</script>
بدائل أجهزة اللمس
واجهة برمجة السحب والإفلات في HTML5 لديها دعم محدود على أجهزة اللمس. بينما تدعمها بعض متصفحات الأجهزة المحمولة مع بعض الحلول البديلة، التجربة غير متسقة. للتطبيقات الإنتاجية التي تحتاج للعمل على الأجهزة المحمولة، يجب تنفيذ سحب وإفلات قائم على اللمس باستخدام واجهة أحداث اللمس كبديل.
مثال: بديل أحداث اللمس للسحب والإفلات
<script>
class TouchDragDrop {
constructor(container, itemSelector) {
this.container = container;
this.itemSelector = itemSelector;
this.draggedItem = null;
this.placeholder = null;
this.offsetX = 0;
this.offsetY = 0;
this.init();
}
init() {
this.container.querySelectorAll(this.itemSelector).forEach(item => {
item.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: false });
item.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false });
item.addEventListener('touchend', this.onTouchEnd.bind(this));
});
}
onTouchStart(e) {
this.draggedItem = e.target.closest(this.itemSelector);
if (!this.draggedItem) return;
const touch = e.touches[0];
const rect = this.draggedItem.getBoundingClientRect();
this.offsetX = touch.clientX - rect.left;
this.offsetY = touch.clientY - rect.top;
// إنشاء عنصر نائب للموضع الأصلي
this.placeholder = this.draggedItem.cloneNode(true);
this.placeholder.style.opacity = '0.3';
this.draggedItem.parentNode.insertBefore(this.placeholder, this.draggedItem);
// تنسيق العنصر المسحوب للتموضع المطلق
this.draggedItem.style.position = 'fixed';
this.draggedItem.style.zIndex = '1000';
this.draggedItem.style.width = rect.width + 'px';
this.draggedItem.style.left = (touch.clientX - this.offsetX) + 'px';
this.draggedItem.style.top = (touch.clientY - this.offsetY) + 'px';
}
onTouchMove(e) {
if (!this.draggedItem) return;
e.preventDefault();
const touch = e.touches[0];
this.draggedItem.style.left = (touch.clientX - this.offsetX) + 'px';
this.draggedItem.style.top = (touch.clientY - this.offsetY) + 'px';
// إيجاد العنصر تحت نقطة اللمس
this.draggedItem.style.display = 'none';
const elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
this.draggedItem.style.display = '';
const targetItem = elementBelow?.closest(this.itemSelector);
if (targetItem && targetItem !== this.placeholder) {
const rect = targetItem.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (touch.clientY < midY) {
this.container.insertBefore(this.placeholder, targetItem);
} else {
this.container.insertBefore(this.placeholder, targetItem.nextSibling);
}
}
}
onTouchEnd(e) {
if (!this.draggedItem || !this.placeholder) return;
// إدراج العنصر المسحوب في موضع العنصر النائب
this.placeholder.parentNode.insertBefore(this.draggedItem, this.placeholder);
this.placeholder.remove();
// إعادة تعيين الأنماط
this.draggedItem.style.position = '';
this.draggedItem.style.zIndex = '';
this.draggedItem.style.width = '';
this.draggedItem.style.left = '';
this.draggedItem.style.top = '';
this.draggedItem = null;
this.placeholder = null;
}
}
// تهيئة السحب والإفلات باللمس
const list = document.getElementById('sortable-list');
new TouchDragDrop(list, 'li');
</script>
'ontouchstart' in window لاكتشاف أجهزة اللمس. للحصول على أفضل تجربة مستخدم، يمكنك دعم كليهما بتهيئة الواجهة الأصلية لمستخدمي الفأرة وبديل اللمس لمستخدمي اللمس. مكتبات مثل interact.js و SortableJS تتعامل مع هذا التوافق عبر الأجهزة تلقائيًا.بناء لوحة كانبان بالسحب والإفلات
لوحة كانبان هي تطبيق السحب والإفلات المثالي. تتميز بأعمدة متعددة (مثل المهام، قيد التنفيذ، ومكتمل) حيث يمكن سحب المهام بين الأعمدة وإعادة ترتيبها داخل الأعمدة. يجمع هذا المثال جميع المفاهيم المغطاة في هذا الدرس في مكون وظيفي كامل.
مثال: لوحة كانبان كاملة
<div class="kanban-board" id="kanban">
<div class="kanban-column" data-status="todo">
<h3>المهام</h3>
<div class="kanban-cards">
<div class="kanban-card" draggable="true" data-id="1">
<h4>تصميم تخطيط الصفحة الرئيسية</h4>
<span class="priority high">عالية</span>
</div>
<div class="kanban-card" draggable="true" data-id="2">
<h4>كتابة وثائق API</h4>
<span class="priority medium">متوسطة</span>
</div>
</div>
</div>
<div class="kanban-column" data-status="in-progress">
<h3>قيد التنفيذ</h3>
<div class="kanban-cards">
<div class="kanban-card" draggable="true" data-id="3">
<h4>بناء مصادقة المستخدم</h4>
<span class="priority high">عالية</span>
</div>
</div>
</div>
<div class="kanban-column" data-status="done">
<h3>مكتمل</h3>
<div class="kanban-cards">
<div class="kanban-card" draggable="true" data-id="4">
<h4>إعداد مستودع المشروع</h4>
<span class="priority low">منخفضة</span>
</div>
</div>
</div>
</div>
<script>
class KanbanBoard {
constructor(boardEl) {
this.board = boardEl;
this.draggedCard = null;
this.init();
}
init() {
// تفويض أحداث السحب من اللوحة
this.board.addEventListener('dragstart', this.onDragStart.bind(this));
this.board.addEventListener('dragend', this.onDragEnd.bind(this));
// إعداد كل عمود كمنطقة إفلات
this.board.querySelectorAll('.kanban-cards').forEach(column => {
column.addEventListener('dragover', this.onDragOver.bind(this));
column.addEventListener('dragenter', this.onDragEnter.bind(this));
column.addEventListener('dragleave', this.onDragLeave.bind(this));
column.addEventListener('drop', this.onDrop.bind(this));
});
}
onDragStart(e) {
const card = e.target.closest('.kanban-card');
if (!card) return;
this.draggedCard = card;
e.dataTransfer.setData('text/plain', card.dataset.id);
e.dataTransfer.effectAllowed = 'move';
requestAnimationFrame(() => {
card.classList.add('dragging');
});
}
onDragEnd(e) {
if (this.draggedCard) {
this.draggedCard.classList.remove('dragging');
this.draggedCard = null;
}
this.board.querySelectorAll('.kanban-cards').forEach(col => {
col.classList.remove('column-highlight');
});
}
onDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const column = e.target.closest('.kanban-cards');
if (!column) return;
const afterElement = this.getInsertionPoint(column, e.clientY);
if (afterElement) {
column.insertBefore(this.draggedCard, afterElement);
} else {
column.appendChild(this.draggedCard);
}
}
onDragEnter(e) {
e.preventDefault();
const column = e.target.closest('.kanban-cards');
if (column) {
column.classList.add('column-highlight');
}
}
onDragLeave(e) {
const column = e.target.closest('.kanban-cards');
if (column && !column.contains(e.relatedTarget)) {
column.classList.remove('column-highlight');
}
}
onDrop(e) {
e.preventDefault();
const column = e.target.closest('.kanban-cards');
if (column) {
column.classList.remove('column-highlight');
}
const newStatus = column.closest('.kanban-column').dataset.status;
const cardId = e.dataTransfer.getData('text/plain');
console.log(`البطاقة ${cardId} انتقلت إلى ${newStatus}`);
// هنا عادة ترسل طلب API لتحديث حالة المهمة على الخادم
}
getInsertionPoint(column, y) {
const cards = [...column.querySelectorAll('.kanban-card:not(.dragging)')];
return cards.reduce((closest, card) => {
const box = card.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: card };
}
return closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
}
// تهيئة لوحة كانبان
const kanban = new KanbanBoard(document.getElementById('kanban'));
</script>
أفضل الممارسات والأخطاء الشائعة
بعد العمل المكثف مع واجهة السحب والإفلات، تبرز عدة ممارسات أفضل ستوفر لك الوقت وتمنع الأخطاء المحبطة.
- استدعِ دائمًا preventDefault() في كل من معالجي
dragoverوdrop. الافتراضي فيdragoverيمنع الإفلات؛ والافتراضي فيdropقد يجعل المتصفح ينتقل إلى البيانات المُفلتة. - استخدم تفويض الأحداث بدلاً من ربط المستمعين بالعناصر القابلة للسحب الفردية. اربط مستمعي
dragstartوdragendبالحاوية الأم واستخدمe.target.closest()لإيجاد العنصر المسحوب. هذا يتعامل مع العناصر المضافة ديناميكيًا تلقائيًا. - تتبع عدادات السحب لـ
dragenterوdragleaveلمنع الوميض. العناصر الفرعية تسبب أحداث دخول ومغادرة إضافية. - استخدم requestAnimationFrame للتغييرات المرئية في
dragstart. إضافة فئات مباشرة فيdragstartيمكن أن تؤثر على الصورة الشبحية التي يلتقطها المتصفح. - عيّن البيانات في dragstart فقط. طريقة
setData()تعمل فقط أثناءdragstart. محاولة استدعائها في أحداث أخرى لن يكون لها أي تأثير. - قيود الأمان: يمكنك قراءة
getData()فقط أثناء حدثdrop. فيdragoverوdragenter، يمكنك التحقق منtypesلكن لا يمكنك قراءة البيانات الفعلية. - وفّر بدائل لإمكانية الوصول وأجهزة اللمس. لا تجعل السحب والإفلات أبدًا الطريقة الوحيدة لإنجاز مهمة.
تمرين عملي
ابنِ تطبيق منظم مهام كامل بالسحب والإفلات. أنشئ ثلاثة أعمدة بعنوان "قائمة الانتظار" و"قيد التنفيذ" و"مكتمل". أضف نموذجًا في الأعلى يسمح للمستخدمين بإنشاء بطاقات مهام جديدة بعنوان ووصف ومستوى أولوية (منخفض ومتوسط وعالٍ). يجب أن تكون كل بطاقة قابلة للسحب بين جميع الأعمدة الثلاثة وقابلة لإعادة الترتيب داخل كل عمود. أضف عدادًا في أعلى كل عمود يُظهر عدد المهام. أضف أزرار حركة بلوحة المفاتيح (سهم يسار وسهم يمين) على كل بطاقة كبديل يمكن الوصول إليه للسحب. عند نقل بطاقة إلى عمود "مكتمل"، أضف تأثير شطب مرئي على عنوانها. نفّذ منطقة إفلات ملفات مرفقة على كل بطاقة حيث يمكن للمستخدمين سحب ملف من سطح المكتب لإرفاقه بالمهمة. أخيرًا، أضف بديل لمس حتى تعمل لوحة كانبان على الأجهزة المحمولة. اختبر تنفيذك بإنشاء خمس مهام على الأقل ونقلها بين جميع الأعمدة باستخدام كل من سحب الفأرة وأزرار لوحة المفاتيح.