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

يعلّمك هذا الدرس التطبيقي حول الترميز كيفية إنشاء تجربة مثل قصص 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 Scroll Snap Points وسلوك التمرير الزائد في مشاركة المدوّنة.

يتطلب الأمر كلاً من الحاوية الرئيسية والعناصر الفرعية للموافقة على محاذاة التمرير، لذا لنتعامل مع ذلك الآن. أضِف الرمز التالي إلى أسفل 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 تسمى تحميل Tombstone:

  • صورة الخلفية 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)) من دليل التخفيف في 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
    }
  }
}

التجربة الآن

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

الخلاصة

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