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

يشرح هذا الدرس التطبيقي حول الترميز كيفية إنشاء تجربة مثل "قصص 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 و&quot;أدوات مطوّري البرامج&quot; مع عرض مرئي لشبكة تعرض التنسيق بالعرض الكامل
&quot;أدوات مطوّري البرامج في Chrome&quot; تعرض تجاوزًا في عمود الشبكة، ما يؤدي إلى إنشاء شريط تمرير أفقي.

أضِف 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 وoverscroll-behavior في منشور المدوّنة.

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

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

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

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

.user

لننشئ تنسيقًا في قسم .user يرتّب عناصر القصة الفرعية هذه في مكانها. سنستخدم حيلة مفيدة لتكديس العناصر من أجل حلّ هذه المشكلة. نحن ننشئ شبكة 1x1 حيث يكون للصف والعمود الاسم المستعار نفسه للشبكة، وهو [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)) من دليل تغيير السرعة في التصميم المتعدد الأبعاد (انتقِل إلى قسم التخفيف المتسارع).

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

JavaScript

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

الإعداد

للبدء، لنحتسب أكبر قدر ممكن من المعلومات ونخزّنها. أضِف الرمز التالي إلى 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
    }
  }
}

للتجربة:

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

الخاتمة

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