יצירת רכיב שניתן לגרירה

תכונות אינטרנט: Pointer Events touch-action user-select

הוספת גרירה לרכיב צף קיים, כמו חלונית, פלטה, סרגל כלים, פקח או תיבת דו-שיח. הגרירה לא קובעת איך הרכיב מוצג, נסגר, נערם או מתמקד.

שימוש בכינוי ייעודי

אם הרכיב מכיל טקסט, כפתורים, שדות קלט או קישורים שאפשר לבחור, מתחילים לגרור מידית ייעודית. כך אפשר להמשיך להשתמש בתוכן הרגיל. הופכים את נקודת האחיזה לניתנת למיקוד לתנועת מקלדת ונותנים לה תווית נגישות שמזכירה את האינטראקציה עם מקשי החיצים.

אל תפעילו role="application" על הידית. הוא משנה את האינטראקציה עם הטכנולוגיה המסייעת שלא לצורך.

צריך להחיל את cursor: grab/grabbing, touch-action: none, וגם את user-select: none ו--webkit-user-select: none על נקודת האחיזה – לא על כל הרכיב שניתן לגרירה – כדי שיהיה אפשר לבחור טקסט מחוץ לו, וכדי שתנועות המגע ישמרו על ההתנהגות הרגילה שלהן:

.drag-handle {
  cursor: grab;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

.drag-handle.dragging {
  cursor: grabbing;
}

שמירה על המיקום של הסמן ועל הנגישות שלו

כשמגבילים את התנועה לאזור התצוגה, צריך להשתמש במודל מיקום יחסי לאזור התצוגה, כמו position: fixed.

  • התחלת גרירה רק באמצעות הכפתור הראשי של הסמן (event.button === 0).
  • מתעדים את היסט הסמן ב-pointerdown ומשתמשים בו בכל תנועה כדי שהרכיב לא יקפוץ כשגוררים אותו מהפינה.
  • הגרירה נשארת פעילה אחרי שהסמן עוזב את נקודת האחיזה. ברירת המחדל היא setPointerCapture() וניקוי מצב הגרירה ב-lostpointercapture. אם יכול להיות שהידית תוסר או תעובד מחדש באמצע הגרירה, צריך להשתמש במאזינים ברמת המסמך pointermove,‏ pointerup ו-pointercancel, כי במקרה כזה התיעוד אובד.
  • אפשר גם להמתין לתנועה של כ-4 פיקסלים לפני הכניסה למצב גרירה, כדי שלחיצה על נקודת האחיזה לא תפעיל את סגנון הגרירה.

כשמשתמשים ב-pointer capture, מאזיני התנועה יכולים להישאר על נקודת האחיזה:

handle.addEventListener('pointerdown', (event) => {
  if (event.button !== 0 || event.target.closest('button, input, select, textarea, a')) return;

  const rect = draggable.getBoundingClientRect();
  offsetX = event.clientX - rect.left;
  offsetY = event.clientY - rect.top;
  handle.setPointerCapture(event.pointerId);
});

handle.addEventListener('pointermove', (event) => {
  if (!handle.hasPointerCapture(event.pointerId)) return;
  handle.classList.add('dragging');
  moveTo(event.clientX - offsetX, event.clientY - offsetY);
});

handle.addEventListener('lostpointercapture', () => {
  handle.classList.remove('dragging');
});

function moveTo(left, top) {
  const maxLeft = Math.max(0, window.innerWidth - draggable.offsetWidth);
  const maxTop = Math.max(0, window.innerHeight - draggable.offsetHeight);

  draggable.style.left = `${Math.max(0, Math.min(left, maxLeft))}px`;
  draggable.style.top = `${Math.max(0, Math.min(top, maxTop))}px`;
}

צריך להגביל כל מיקום של הסמן והמקלדת באמצעות אותו כלי עזר כדי שהרכיב יישאר נגיש. מצמידים מחדש את המיקום הנוכחי של הרכיב אחרי שינויים באזור התצוגה או בגודל הרכיב. ברכיבים עם פריסה מורכבת, כדאי להשתמש ב-translate במהלך הגרירה כדי להימנע מפריסה לכל פריים, ואז להגדיר את המיקום הסופי ב-left וב-top כשמשחררים את הרכיב.

תנועה באמצעות המקלדת

כשהידית נמצאת במיקוד, מזיזים את הרכיב באמצעות מקשי החיצים ומפעילים את preventDefault() עבור המקשים האלה. משתמשים באותו כלי עזר למיקום מוגבל כמו בתנועת הסמן. תוספת של 15 פיקסלים היא נקודת התחלה סבירה. אפשר להתאים אותה לגודל הרכיב ולצרכי הדיוק.

הערה ספציפית לתיבת הדו-שיח

<dialog> או [popover] יכולים להשתמש במנגנון הזה עם הכותרת שלו בתור נקודת האחיזה. צריך להגדיר את margin: 0 לפני שמקצים את left ו-top, כי שולי ברירת המחדל של הדפדפן מפריעים למיקום הידני. למידע על ההתנהגות של פתיחה, סגירה, מיקוד וסגירה של תיבות דו-שיח וחלונות קופצים, אפשר לעיין במאמרים Declarative Dialog and Popover Control ו-Platform Controls Dismiss Dialog.

אסטרטגיות חלופיות

אם גרירה רק משנה את המיקום של רכיב, צריך להתייחס אליה כאל שיפור הדרגתי: בלי JavaScript, צריך לשמור על הרכיב, התוכן שלו והפקדים שלו גלויים, קריאים ופונקציונליים במיקום ברירת מחדל הגיוני. אל תחשפו נקודת אחיזה לא פעילה: אם נקודת האחיזה היא פקד אחיזה עצמאי, הסתירו אותה כברירת מחדל וחשפו אותה כש-JavaScript מאתחל. אם כותרת משמשת גם כנקודת אחיזה, צרפו את אפשרויות הגרירה שלה כש-JavaScript מאתחל.

צריך לכלול את -webkit-user-select: none לפני user-select: none כדי שבחירת הטקסט תישאר מבוטלת במהלך הגרירה ב-Safari.