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

يعلّمك هذا الدرس التطبيقي حول الترميز كيفية إنشاء تجربة مشابهة لـ "قصص 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">، نريد حاوية يمكن التمرير فيها أفقيًا. يمكننا تحقيق ذلك من خلال:

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

ستواصل Grid وضع أعمدة جديدة بعرض 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)) من دليل التباطؤ والتسارع في Material Design (انتقِل إلى قسم التباطؤ والتسارع المتسارع).

إذا كنت منتبهًا، ربما لاحظت تعريف 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، سنبحث فيه عن الأصدقاء (المستخدمين) أو القصص (story).

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

أضِف الرمز التالي إلى أسفل 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
    }
  }
}

جرّبه الآن

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

الخاتمة

هذا كل ما أردت قوله بشأن احتياجاتي المتعلقة بالمكوّن. يمكنك الاستفادة من هذا النموذج وتعديله بما يناسبك.