درس تطبيقي حول الترميز: إنشاء مكوِّن للقصص

يعلّمك هذا الدرس التطبيقي كيفية إنشاء تجربة مثل قصص Instagram على الويب. سننشئ المكون مع تقدمنا، بدءًا بـ HTML، ثم CSS، ثم على JavaScript.

يمكنك الاطّلاع على مشاركة مدونتي بعنوان إنشاء مكوِّن من القصص للتعرف على التحسينات التدريجية التي يتم إجراؤها أثناء إنشاء هذا المكون.

ضبط إعدادات الجهاز

  1. انقر على إنشاء ريمكس لتعديل لجعل المشروع قابلاً للتعديل.
  2. فتح "app/index.html"

HTML

أسعى دائمًا إلى استخدام HTML الدلالي. وبما أن كل صديق يمكن أن يكون له أي عدد من القصص، فقد اعتقدت أنه من المفيد استخدام العنصر <section> لكل صديق وعنصر <article> لكل قصة. لنبدأ من البداية. أولاً، نحتاج إلى حاوية مكون القصص.

إضافة عنصر <div> إلى <body>:

<div class="stories">

</div>

إضافة بعض عناصر <section> لتمثيل الأصدقاء:

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

إضافة بعض عناصر <article> لتمثيل الأخبار:

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • نستخدم خدمة صور (picsum.com) للمساعدة في إنشاء نماذج أوّلية للقصص.
  • السمة style في كل <article> هي جزء من عنصر نائب يتم تحميله والتي ستتعرف على المزيد حولها في القسم التالي.

CSS

المحتوى جاهز للاستخدام. دعونا نحول هذه العظام إلى شيء يشبه الناس نريد التفاعل معهم. سنعمل اليوم على الأجهزة الجوّالة أولاً.

.stories

بالنسبة إلى حاوية <div class="stories">، نريد حاوية تمرير أفقي. ويمكننا تحقيق ذلك من خلال ما يلي:

  • ضبط الحاوية على شكل شبكة
  • ضبط كل طفل على ملء مسار الصف
  • عرض كل عنصر فرعي بعرض إطار عرض جهاز جوّال

ستستمر الشبكة في وضع الأعمدة الجديدة التي يبلغ عرضها 100vw على يمين العمود السابق. أولاً، إلى أن يتم وضع جميع عناصر HTML في الترميز

فتح Chrome وأدوات مطوّري البرامج مع عرض مرئي على شكل مربّعات يعرض التنسيق بالعرض الكامل
أدوات مطوري البرامج في Chrome تعرض تجاوزًا لأعمدة الشبكة، ويتم فيها إنشاء شريط تمرير أفقي.

أضِف خدمة مقارنة الأسعار (CSS) التالية إلى أسفل app/css/index.css:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

والآن بعد أن أصبح لدينا محتوى يمتد إلى ما بعد إطار العرض، حان الوقت لإخبار ذلك الحاوية حول كيفية التعامل معها. أضِف سطور الرمز المميّزة إلى مجموعة قواعد .stories:

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

نريد التمرير الأفقي، لذلك سنقوم بتعيين overflow-x على auto عندما يقوم المستخدم بالتمرير، نريد أن يستقر المكون برفق على القصة التالية، لذلك سنستخدم scroll-snap-type: x mandatory. مزيد من المعلومات حول هذا الموضوع CSS في نقاط محاذاة التمرير في CSS وسلوك التمرير الزائد الأقسام الخاصة بمنشور مدونتي.

يتطلب الأمر توافق كل من الحاوية الرئيسية والعناصر الفرعية على توافق محاذاة التمرير، دعنا نتعامل مع ذلك الآن. أضِف الرمز التالي إلى أسفل app/css/index.css:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

لا يعمل تطبيقك بعد، ولكن يوضّح الفيديو أدناه ما يحدث عندما تم تفعيل scroll-snap-type وإيقافها. عند تفعيل كل تنسيق أفقي يؤدي التمرير إلى الانتقال إلى القصة التالية. عند الإيقاف، يستخدم المتصفح السلوك الافتراضي للتمرير.

سيؤدي ذلك إلى تصفُّح أصدقائك، ولكن لا تزال لدينا مشكلة. بالقصص لحلها.

.user

لننشئ تنسيقًا في القسم .user لترتيب هذه القصة الفرعية. العناصر في مكانها الصحيح. سنستخدم حيلة تجميع سهلة الاستخدام لحل هذه المشكلة. نحن في الأساس ننشئ شبكة 1×1 حيث يكون للصف والعمود نفس الشبكة الاسم المستعار لـ [story]، وسيحاول كل عنصر من عناصر شبكة القصة المطالبة بهذه المساحة، مما يؤدي إلى إنشاء مكدس.

أضِف الرمز المميّز إلى مجموعة قواعد .user:

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

إضافة مجموعة القواعد التالية إلى أسفل app/css/index.css:

.story {
  grid-area: story;
}

والآن، بدون تحديد الموضع المطلق أو الأعداد العشرية أو توجيهات التخطيط الأخرى التي تتخذ خارج تدفق عنصر ما، فإننا لا نزال في تدفق. بالإضافة إلى ذلك، إنه يشبه تقريبًا أي رمز، انظر إلى ذلك! يتم تقسيم ذلك في الفيديو وفي مشاركة المدونة بمزيد من التفصيل.

.story

نحتاج الآن فقط إلى تصميم عنصر القصة نفسه.

ذكرنا سابقًا أنّ السمة style في كل عنصر <article> هي جزء أسلوب تحميل العنصر النائب:

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

سنستخدم سمة background-image في CSS، والتي تتيح لنا تحديد تتوفر أكثر من صورة خلفية واحدة. يمكننا وضعها بترتيب حتى يتمكن مستخدمنا الصورة في الأعلى وستظهر تلقائيًا عند انتهاء التحميل. إلى تفعيل هذا، سنضع عنوان URL للصورة في خاصية مخصصة (--bg)، ونستخدمه داخل CSS لوضع طبقات مع العنصر النائب للتحميل.

أولاً، سنُعدّل مجموعة قواعد ".story" لاستبدال تدرج بصورة خلفية. عند انتهاء التحميل أضِف الرمز المميّز إلى مجموعة قواعد .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

يضمن ضبط background-size على cover عدم وجود أي مساحة فارغة في إطار العرض لأن صورتنا ستملأه. جارٍ تحديد صورتين للخلفية من تنفيذ حيلة ويب أنيقة في CSS تُعرف باسم سجلّ التحميل:

  • صورة الخلفية 1 (var(--bg)) هي عنوان URL الذي مررنا به مضمّنًا في HTML
  • صورة الخلفية 2 (linear-gradient(to top, lch(98 0 0), lch(90 0 0)) عبارة عن تدرج لوني لعرضه أثناء تحميل عنوان URL

ستستبدل خدمة CSS تلقائيًا التدرج بالصورة، فور الانتهاء من تنزيل الصورة.

بعد ذلك، سنضيف بعض ترميز CSS لإزالة بعض السلوكيات، ما يجعل المتصفِّح يتحرك بشكل أسرع. أضِف الرمز المميّز إلى مجموعة قواعد .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • تمنع الدالة user-select: none المستخدمين من اختيار النص عن طريق الخطأ.
  • تُوجه touch-action: manipulation المتصفح إلى أن هذه التفاعلات كما ينبغي التعامل مع أحداث اللمس، الأمر الذي يُحرر المتصفح من محاولة عليك تحديد ما إذا كنت تنقر على عنوان URL أم لا

أخيرًا، لنُضف جزءًا صغيرًا من CSS لتحريك نقطتَي الانتقال بين القصص. إضافة الرمز المميّز إلى مجموعة قواعد .story:

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

ستتم إضافة الصف .seen إلى القصة التي تحتاج إلى مخرج. حصلت على دالة التخفيف المخصصة (cubic-bezier(0.4, 0.0, 1,1)) من Easing في Material Design الدليل (انتقِل إلى قسم التخفيف المخطَّط).

إذا كنت تتوخى الحذر، لحظت على الأرجح "pointer-events: none" يحكون ذهنك الآن. أود أن أقول أن هذا هو الجانب السلبي للحل حتى الآن. نحتاج إلى هذه السمة لأنّ العنصر .seen.story ستكون في الأعلى وتلقى نقرات، حتى إذا كانت غير مرئية. من خلال تعيين pointer-events إلى none، حوّلنا القصة الزجاجية إلى نافذة ولم نسرق المزيد من تفاعلات المستخدم. مقايضة ليست سيئة جدًا، ولكن ليس من الصعب جدًا إدارتها في CSS حاليًا. نحن لا نستخدم "z-index" للتلاعب. أشعر بالرضا عن هذا الأمر ساكن.

JavaScript

تكون التفاعلات مع مكوِّن القصص سهلة جدًا للمستخدم: انقر على اليمين للتقدم للأمام، انقر على اليمين للرجوع. تميل الأشياء البسيطة للمستخدمين عملاً شاقًا للمطورين. ومع ذلك، سنهتم بالكثير منها.

ضبط إعدادات الجهاز

للبدء، دعنا نحسب أكبر قدر ممكن من المعلومات ونخزنها. أضِف الرمز التالي إلى app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

يحصل السطر الأول من JavaScript على مرجع إلى HTML الأساسي ويخزّنه جذر العنصر. يحسب السطر التالي مكان منتصف العنصر، لذلك يمكنه تحديد ما إذا كانت النقر للانتقال للأمام أو للخلف.

الحالة

بعد ذلك، ننشئ كائنًا صغيرًا له حالة ذات صلة بمنطقنا. في هذه الدورة، في هذه الحالة، فنحن مهتمون فقط بالقصة الحالية. في ترميز HTML، يمكننا يمكنك الوصول إليها من خلال جمع أول صديق وأحدث قصته. إضافة الرمز المميّز إلى app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

أدوات معالجة الأحداث

لدينا منطق كاف الآن لبدء الاستماع إلى أحداث المستخدمين وتوجيهها.

فأر

لنبدأ بالاستماع إلى حدث 'click' على حاوية القصص. إضافة الرمز المميّز إلى app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

وإذا حدثت نقرة ولم تكن على عنصر <article>، نستفيد من ذلك ولا نتّخذ أي إجراء. إذا كانت المقالة عبارة عن مقالة، نمسك الموضع الأفقي للماوس أو الإصبع باستخدام clientX لم نقم بتنفيذ navigateStories بعد، ولكن الوسيطة التي يحدد الاتجاه الذي ينبغي أن نسير فيه. إذا كان موضع المستخدم هذا أكبر من المتوسط، فإننا نعلم أننا بحاجة إلى الانتقال إلى next، وإلا prev (السابق).

لوحة المفاتيح

الآن، لنستمع إلى ضغطات لوحة المفاتيح. إذا تم الضغط على السهم المتجه للأسفل، ننتقل إلى إلى next. إذا كان لديك السهم المتّجه للأعلى، سننتقل إلى prev.

إضافة الرمز المميّز إلى app/js/index.js:

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

التنقّل في القصص

حان الوقت لمعالجة منطق الأعمال الفريد للقصص وتجربة المستخدم التي أصبحت الشهيرة. يبدو هذا سميكًا وصعبًا، لكنني أعتقد أنك إذا تعاملت معه ستجد أنه يسهل فهمه تمامًا.

في البداية، نخزّن بعض أدوات الاختيار التي تساعدنا في اتخاذ قرار الانتقال إلى صديق أو إظهار/إخفاء قصة. نظرًا لأن HTML هو المكان الذي نعمل فيه، فسنكون الاستعلام عن وجود الأصدقاء (المستخدمين) أو القصص (القصة).

ستساعدنا هذه المتغيرات في الإجابة عن أسئلة مثل، "معنى القصة س، هل "التالي" الانتقال إلى قصة أخرى من هذا الصديق نفسه أو إلى صديق آخر؟" لقد فعلت ذلك باستخدام الشجرة الهيكل الذي أنشأناه، والوصول إلى أولياء الأمور وأطفالهم.

أضِف الرمز التالي إلى أسفل app/js/index.js:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

في ما يلي هدف منطق الأنشطة التجارية، والذي يكون أقرب ما يمكن إلى اللغة الطبيعية:

  • تحديد كيفية التعامل مع النقر
    • إذا كانت لديك قصة تالية/سابقة: اعرض تلك القصة
    • إذا كانت هذه القصة الأخيرة/الأولى للصديق: اعرض صديقًا جديدًا
    • إذا لم تكن هناك قصة يمكن اتباعها في هذا الاتجاه: لا تفعل أي شيء
  • تخزين القصة الحالية الجديدة في state

أضف الرمز المميز إلى دالة navigateStories:

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

جرّبه الآن

  • لمعاينة الموقع الإلكتروني، اضغط على عرض التطبيق. ثم اضغط ملء الشاشة ملء الشاشة

الخاتمة

هذا ملخص للاحتياجات التي كانت لدي مع المكون. لا تتردد في البناء على باستخدام البيانات، وبشكل عام، اجعلها ملكك!